spine-framework 0.3.55 → 0.3.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.framework/cli/commands/dev.ts +19 -0
- package/.framework/cli/commands/install-app.ts +89 -1
- package/.framework/functions/invites.ts +18 -0
- package/.framework/migrations/016_invites.sql +27 -0
- package/.framework/src/pages/auth/RegisterPage.tsx +150 -38
- package/config/vite.config.ts +4 -0
- package/package.json +3 -1
- package/scripts/assemble-frontend.sh +7 -0
- package/spine.config.json +20 -0
|
@@ -255,6 +255,25 @@ async function fastReassemble(projectRoot: string): Promise<void> {
|
|
|
255
255
|
copyChangedFiles(customFuncsDir, funcsDest)
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
|
+
|
|
259
|
+
// Reassemble spine.config.json to public directory
|
|
260
|
+
const spineConfigSrc = resolve(projectRoot, 'spine.config.json')
|
|
261
|
+
const publicDest = resolve(srcDest, 'public')
|
|
262
|
+
if (existsSync(spineConfigSrc)) {
|
|
263
|
+
if (!existsSync(publicDest)) {
|
|
264
|
+
mkdirSync(publicDest, { recursive: true })
|
|
265
|
+
}
|
|
266
|
+
const spineConfigDest = resolve(publicDest, 'spine.config.json')
|
|
267
|
+
const srcStat = statSync(spineConfigSrc)
|
|
268
|
+
let needsCopy = true
|
|
269
|
+
if (existsSync(spineConfigDest)) {
|
|
270
|
+
const destStat = statSync(spineConfigDest)
|
|
271
|
+
needsCopy = srcStat.mtimeMs > destStat.mtimeMs
|
|
272
|
+
}
|
|
273
|
+
if (needsCopy) {
|
|
274
|
+
copyFileSync(spineConfigSrc, spineConfigDest)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
258
277
|
}
|
|
259
278
|
|
|
260
279
|
/**
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import type { Command } from 'commander'
|
|
21
|
-
import { existsSync, readFileSync, mkdirSync, cpSync } from 'fs'
|
|
21
|
+
import { existsSync, readFileSync, mkdirSync, cpSync, writeFileSync } from 'fs'
|
|
22
22
|
import { resolve, dirname, join, basename } from 'path'
|
|
23
23
|
import { fileURLToPath } from 'url'
|
|
24
24
|
import { execSync } from 'child_process'
|
|
@@ -81,6 +81,14 @@ export function registerInstallAppCommands(program: Command) {
|
|
|
81
81
|
await seedApp(appDest, appSlug)
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// 6. Aggregate registration config from manifest into spine.config.json
|
|
85
|
+
const manifestPath = join(appDest, 'manifest.json')
|
|
86
|
+
let manifest: Record<string, any> = {}
|
|
87
|
+
try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) } catch {}
|
|
88
|
+
if (manifest.registration?.enabled) {
|
|
89
|
+
updateRegistrationConfig(appSlug, manifest.registration)
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
console.log(`\n✅ ${appSlug} installed successfully!\n`)
|
|
85
93
|
console.log('Next: npm run assemble && netlify dev\n')
|
|
86
94
|
|
|
@@ -153,6 +161,14 @@ export function registerInstallAppCommands(program: Command) {
|
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
163
|
|
|
164
|
+
// Update registration config from manifest
|
|
165
|
+
const manifestPath = join(appDest, 'manifest.json')
|
|
166
|
+
let manifest: Record<string, any> = {}
|
|
167
|
+
try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) } catch {}
|
|
168
|
+
if (manifest.registration?.enabled) {
|
|
169
|
+
updateRegistrationConfig(appSlug, manifest.registration)
|
|
170
|
+
}
|
|
171
|
+
|
|
156
172
|
console.log(`\n✅ ${appSlug} updated.\n`)
|
|
157
173
|
} catch (err: any) {
|
|
158
174
|
console.error(`\n❌ Update failed: ${err.message}`)
|
|
@@ -278,3 +294,75 @@ async function seedApp(appDir: string, appSlug: string) {
|
|
|
278
294
|
}
|
|
279
295
|
}
|
|
280
296
|
}
|
|
297
|
+
|
|
298
|
+
// ─── SPINE CONFIG HELPERS ───────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
interface RegistrationConfig {
|
|
301
|
+
enabled: boolean
|
|
302
|
+
default_role: string
|
|
303
|
+
redirect_path: string
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
interface SpineConfig {
|
|
307
|
+
registration?: {
|
|
308
|
+
default_app?: string
|
|
309
|
+
default_role?: string
|
|
310
|
+
allow_url_override?: boolean
|
|
311
|
+
apps?: Record<string, {
|
|
312
|
+
role: string
|
|
313
|
+
path: string
|
|
314
|
+
enabled: boolean
|
|
315
|
+
}>
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const SPINE_CONFIG_PATH = join(PROJECT_ROOT, 'spine.config.json')
|
|
320
|
+
|
|
321
|
+
function loadSpineConfig(): SpineConfig {
|
|
322
|
+
if (!existsSync(SPINE_CONFIG_PATH)) {
|
|
323
|
+
return { registration: { apps: {} } }
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
return JSON.parse(readFileSync(SPINE_CONFIG_PATH, 'utf8'))
|
|
327
|
+
} catch {
|
|
328
|
+
return { registration: { apps: {} } }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function saveSpineConfig(config: SpineConfig): void {
|
|
333
|
+
writeFileSync(SPINE_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function updateRegistrationConfig(appSlug: string, regConfig: RegistrationConfig): void {
|
|
337
|
+
const config = loadSpineConfig()
|
|
338
|
+
|
|
339
|
+
// Ensure registration section exists
|
|
340
|
+
if (!config.registration) {
|
|
341
|
+
config.registration = { apps: {} }
|
|
342
|
+
}
|
|
343
|
+
if (!config.registration.apps) {
|
|
344
|
+
config.registration.apps = {}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Add/update this app's registration entry
|
|
348
|
+
config.registration.apps[appSlug] = {
|
|
349
|
+
role: regConfig.default_role,
|
|
350
|
+
path: regConfig.redirect_path,
|
|
351
|
+
enabled: regConfig.enabled,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// If this is the first registration-enabled app, make it the default
|
|
355
|
+
const enabledApps = Object.entries(config.registration.apps).filter(([, cfg]) => cfg.enabled)
|
|
356
|
+
if (enabledApps.length === 1 || !config.registration.default_app) {
|
|
357
|
+
config.registration.default_app = appSlug
|
|
358
|
+
config.registration.default_role = regConfig.default_role
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Default allow_url_override to true if not set
|
|
362
|
+
if (config.registration.allow_url_override === undefined) {
|
|
363
|
+
config.registration.allow_url_override = true
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
saveSpineConfig(config)
|
|
367
|
+
console.log(` ✓ Updated spine.config.json registration for ${appSlug}`)
|
|
368
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* **Public actions (no auth required):**
|
|
14
14
|
* | action | method | description |
|
|
15
15
|
* |-------------------|--------|-------------|
|
|
16
|
+
* | check-first-user | GET | Boolean: is this the first user registration? |
|
|
16
17
|
* | check-domain | GET | Boolean: does this email domain have existing accounts? |
|
|
17
18
|
* | validate-token | GET | Validate invite token, return preview |
|
|
18
19
|
* | complete-registration | POST | Finalise walk-up registration after Supabase auth |
|
|
@@ -93,6 +94,22 @@ function isAdminRole(roleSlug: string | undefined): boolean {
|
|
|
93
94
|
)
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
// ─── PUBLIC: check-first-user ─────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Returns { is_first_user: boolean } — true if there are zero people in the system.
|
|
101
|
+
* Used by RegisterPage to show simplified first-user flow.
|
|
102
|
+
*/
|
|
103
|
+
export const checkFirstUser = createHandler(async (_ctx, _body) => {
|
|
104
|
+
const { count, error } = await adminDb
|
|
105
|
+
.from('people')
|
|
106
|
+
.select('*', { count: 'exact', head: true })
|
|
107
|
+
|
|
108
|
+
if (error) throw new Error('Failed to check first user: ' + error.message)
|
|
109
|
+
|
|
110
|
+
return { is_first_user: (count ?? 0) === 0 }
|
|
111
|
+
})
|
|
112
|
+
|
|
96
113
|
// ─── PUBLIC: check-domain ─────────────────────────────────────────────────────
|
|
97
114
|
|
|
98
115
|
/**
|
|
@@ -621,6 +638,7 @@ export const handler = createHandler(async (ctx, body) => {
|
|
|
621
638
|
|
|
622
639
|
switch (action) {
|
|
623
640
|
// Public
|
|
641
|
+
case 'check-first-user': return await checkFirstUser(ctx, body)
|
|
624
642
|
case 'check-domain': return await checkDomain(ctx, body)
|
|
625
643
|
case 'validate-token': return await validateToken(ctx, body)
|
|
626
644
|
case 'complete-registration': return await completeRegistration(ctx, body)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- Migration 016: Invite system indexes
|
|
2
|
+
-- Invite and access_request stubs are stored as people rows with status
|
|
3
|
+
-- 'invited' or 'pending' and invite metadata in the data jsonb column.
|
|
4
|
+
--
|
|
5
|
+
-- people.status values:
|
|
6
|
+
-- active — normal active person
|
|
7
|
+
-- inactive — deactivated
|
|
8
|
+
-- invited — admin-generated invite stub, unclaimed (pre-approval by definition)
|
|
9
|
+
-- pending — walk-up access request stub, awaiting member_admin approval
|
|
10
|
+
--
|
|
11
|
+
-- Invite metadata stored in people.data:
|
|
12
|
+
-- invite_token text — 16-char random lookup key (URL: /register?t=TOKEN)
|
|
13
|
+
-- invite_kind text — 'invite' | 'access_request'
|
|
14
|
+
-- invite_expires_at text — ISO timestamp
|
|
15
|
+
-- invite_attempts integer — failed validation attempts
|
|
16
|
+
-- invite_blocked_at text — ISO timestamp, set after too many attempts
|
|
17
|
+
-- invite_invited_by uuid — person UUID of the inviter
|
|
18
|
+
|
|
19
|
+
-- Fast token lookup
|
|
20
|
+
CREATE INDEX IF NOT EXISTS people_invite_token_idx
|
|
21
|
+
ON public.people ((data->>'invite_token'))
|
|
22
|
+
WHERE data->>'invite_token' IS NOT NULL;
|
|
23
|
+
|
|
24
|
+
-- Efficient scoped invite list queries
|
|
25
|
+
CREATE INDEX IF NOT EXISTS people_invite_kind_account_idx
|
|
26
|
+
ON public.people (account_id, status)
|
|
27
|
+
WHERE data->>'invite_kind' IS NOT NULL;
|
|
@@ -4,21 +4,28 @@
|
|
|
4
4
|
* @layer frontend-page
|
|
5
5
|
* @stability stable
|
|
6
6
|
*
|
|
7
|
-
* Registration page. Handles
|
|
7
|
+
* Registration page. Handles multiple paths:
|
|
8
8
|
*
|
|
9
|
-
* 1.
|
|
9
|
+
* 1. First user (no people in system) — simplified form, becomes system_admin.
|
|
10
|
+
* No personal/company toggle shown.
|
|
11
|
+
*
|
|
12
|
+
* 2. Invite link (?t=TOKEN) — email + account pre-filled and locked.
|
|
10
13
|
* On submit: Supabase signUp → auth trigger resolves stub → active.
|
|
11
14
|
*
|
|
12
|
-
*
|
|
15
|
+
* 3. Walk-up personal — new account named after the person.
|
|
13
16
|
* On submit: Supabase signUp → complete-registration(new_account).
|
|
14
17
|
*
|
|
15
|
-
*
|
|
18
|
+
* 4. Walk-up company, no domain match — new account with company name.
|
|
16
19
|
* On submit: Supabase signUp → complete-registration(new_account).
|
|
17
20
|
*
|
|
18
|
-
*
|
|
21
|
+
* 5. Walk-up company, domain match — user chooses join or create new.
|
|
19
22
|
* Join: Supabase signUp → complete-registration(join_request) → pending.
|
|
20
23
|
* Create new: same as path 3.
|
|
21
24
|
*
|
|
25
|
+
* URL params:
|
|
26
|
+
* ?app=portal|cortex — override default registration app
|
|
27
|
+
* ?role=member|support — override default role
|
|
28
|
+
*
|
|
22
29
|
* @seeAlso functions/invites.ts (check-domain, validate-token, complete-registration)
|
|
23
30
|
* @seeAlso migrations/005_auth_user_trigger.sql (invite stub resolution)
|
|
24
31
|
* @seeAlso src/pages/auth/PendingApprovalPage.tsx
|
|
@@ -41,6 +48,17 @@ interface InvitePreview {
|
|
|
41
48
|
kind: string
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
interface RegistrationConfig {
|
|
52
|
+
default_app: string
|
|
53
|
+
default_role: string
|
|
54
|
+
allow_url_override: boolean
|
|
55
|
+
apps: Record<string, {
|
|
56
|
+
role: string
|
|
57
|
+
path: string
|
|
58
|
+
enabled: boolean
|
|
59
|
+
}>
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
|
45
63
|
|
|
46
64
|
async function callInvitesApi(action: string, params: Record<string, any> = {}, method: 'GET' | 'POST' = 'GET') {
|
|
@@ -58,6 +76,54 @@ async function callInvitesApi(action: string, params: Record<string, any> = {},
|
|
|
58
76
|
return res.json()
|
|
59
77
|
}
|
|
60
78
|
|
|
79
|
+
async function loadRegistrationConfig(): Promise<RegistrationConfig | null> {
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch('/spine.config.json')
|
|
82
|
+
if (!res.ok) return null
|
|
83
|
+
const config = await res.json()
|
|
84
|
+
return config.registration ?? null
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function checkFirstUser(): Promise<boolean> {
|
|
91
|
+
try {
|
|
92
|
+
const result = await callInvitesApi('check-first-user')
|
|
93
|
+
return result.data?.is_first_user ?? false
|
|
94
|
+
} catch {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getPostRegistrationRedirect(
|
|
100
|
+
config: RegistrationConfig | null,
|
|
101
|
+
searchParams: URLSearchParams,
|
|
102
|
+
isInvite: boolean,
|
|
103
|
+
inviteAccountId?: string
|
|
104
|
+
): string {
|
|
105
|
+
// Invites: redirect to the account's primary app or default
|
|
106
|
+
if (isInvite) {
|
|
107
|
+
return '/'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check for URL override if allowed
|
|
111
|
+
if (config?.allow_url_override) {
|
|
112
|
+
const appOverride = searchParams.get('app')
|
|
113
|
+
if (appOverride && config.apps[appOverride]?.enabled) {
|
|
114
|
+
return config.apps[appOverride].path
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Default app from config
|
|
119
|
+
if (config?.default_app && config.apps[config.default_app]?.enabled) {
|
|
120
|
+
return config.apps[config.default_app].path
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fallback
|
|
124
|
+
return '/'
|
|
125
|
+
}
|
|
126
|
+
|
|
61
127
|
// ─── COMPONENT ────────────────────────────────────────────────────────────────
|
|
62
128
|
|
|
63
129
|
export function RegisterPage() {
|
|
@@ -65,6 +131,10 @@ export function RegisterPage() {
|
|
|
65
131
|
const navigate = useNavigate()
|
|
66
132
|
const inviteToken = searchParams.get('t')
|
|
67
133
|
|
|
134
|
+
// Registration config
|
|
135
|
+
const [regConfig, setRegConfig] = useState<RegistrationConfig | null>(null)
|
|
136
|
+
const [isFirstUser, setIsFirstUser] = useState<boolean | null>(null)
|
|
137
|
+
|
|
68
138
|
// Invite preview (when token present)
|
|
69
139
|
const [invitePreview, setInvitePreview] = useState<InvitePreview | null>(null)
|
|
70
140
|
const [tokenError, setTokenError] = useState('')
|
|
@@ -85,7 +155,19 @@ export function RegisterPage() {
|
|
|
85
155
|
const [error, setError] = useState('')
|
|
86
156
|
const [isLoading, setIsLoading] = useState(false)
|
|
87
157
|
|
|
88
|
-
// ──
|
|
158
|
+
// ── Load config and check first user on mount ──────────────────────────────
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
;(async () => {
|
|
161
|
+
const [config, firstUserCheck] = await Promise.all([
|
|
162
|
+
loadRegistrationConfig(),
|
|
163
|
+
checkFirstUser(),
|
|
164
|
+
])
|
|
165
|
+
setRegConfig(config)
|
|
166
|
+
setIsFirstUser(firstUserCheck)
|
|
167
|
+
})()
|
|
168
|
+
}, [])
|
|
169
|
+
|
|
170
|
+
// ── Resolve invite token ───────────────────────────────────────────────────
|
|
89
171
|
useEffect(() => {
|
|
90
172
|
if (!inviteToken) return
|
|
91
173
|
;(async () => {
|
|
@@ -103,7 +185,7 @@ export function RegisterPage() {
|
|
|
103
185
|
|
|
104
186
|
// ── Domain check on email blur ─────────────────────────────────────────────
|
|
105
187
|
const handleEmailBlur = useCallback(async () => {
|
|
106
|
-
if (!email || !email.includes('@') || inviteToken || useType === 'personal') return
|
|
188
|
+
if (!email || !email.includes('@') || inviteToken || useType === 'personal' || isFirstUser) return
|
|
107
189
|
|
|
108
190
|
setDomainCheckLoading(true)
|
|
109
191
|
setDomainMatchAccountId(null)
|
|
@@ -113,10 +195,9 @@ export function RegisterPage() {
|
|
|
113
195
|
setDomainCheckLoading(false)
|
|
114
196
|
|
|
115
197
|
if (result.data?.match) {
|
|
116
|
-
// We signal a match but don't expose the account id here (fetched server-side on join)
|
|
117
198
|
setDomainMatchAccountId('__matched__')
|
|
118
199
|
}
|
|
119
|
-
}, [email, inviteToken, useType])
|
|
200
|
+
}, [email, inviteToken, useType, isFirstUser])
|
|
120
201
|
|
|
121
202
|
// ── Submit ─────────────────────────────────────────────────────────────────
|
|
122
203
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
@@ -126,7 +207,7 @@ export function RegisterPage() {
|
|
|
126
207
|
|
|
127
208
|
try {
|
|
128
209
|
// 1. Supabase auth signup
|
|
129
|
-
const { error: signUpError } = await supabase.auth.signUp({
|
|
210
|
+
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
|
|
130
211
|
email: invitePreview?.email ?? email,
|
|
131
212
|
password,
|
|
132
213
|
options: { data: { full_name: fullName } },
|
|
@@ -137,13 +218,20 @@ export function RegisterPage() {
|
|
|
137
218
|
return
|
|
138
219
|
}
|
|
139
220
|
|
|
140
|
-
// 2.
|
|
221
|
+
// 2. First user path — trigger handles everything, navigate to root
|
|
222
|
+
if (isFirstUser) {
|
|
223
|
+
// First user becomes system_admin immediately, go to admin
|
|
224
|
+
navigate('/spine-framework/admin', { replace: true })
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 3. Invite path — trigger handles everything, navigate to account
|
|
141
229
|
if (inviteToken) {
|
|
142
|
-
navigate('/
|
|
230
|
+
navigate('/', { replace: true })
|
|
143
231
|
return
|
|
144
232
|
}
|
|
145
233
|
|
|
146
|
-
//
|
|
234
|
+
// 4. Walk-up paths — call complete-registration
|
|
147
235
|
const resolvedEmail = email
|
|
148
236
|
|
|
149
237
|
if (domainMatchChoice === 'join') {
|
|
@@ -157,7 +245,7 @@ export function RegisterPage() {
|
|
|
157
245
|
setError(joinResult.error)
|
|
158
246
|
return
|
|
159
247
|
}
|
|
160
|
-
navigate('/pending-approval')
|
|
248
|
+
navigate('/pending-approval', { replace: true })
|
|
161
249
|
return
|
|
162
250
|
}
|
|
163
251
|
|
|
@@ -174,7 +262,9 @@ export function RegisterPage() {
|
|
|
174
262
|
return
|
|
175
263
|
}
|
|
176
264
|
|
|
177
|
-
|
|
265
|
+
// Navigate to configured post-registration destination
|
|
266
|
+
const redirectPath = getPostRegistrationRedirect(regConfig, searchParams, false)
|
|
267
|
+
navigate(redirectPath, { replace: true })
|
|
178
268
|
} catch (err: any) {
|
|
179
269
|
setError(err.message || 'Registration failed')
|
|
180
270
|
} finally {
|
|
@@ -182,8 +272,8 @@ export function RegisterPage() {
|
|
|
182
272
|
}
|
|
183
273
|
}
|
|
184
274
|
|
|
185
|
-
// ──
|
|
186
|
-
if (tokenLoading) {
|
|
275
|
+
// ── Loading states ───────────────────────────────────────────────────────────
|
|
276
|
+
if (tokenLoading || isFirstUser === null) {
|
|
187
277
|
return (
|
|
188
278
|
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
|
189
279
|
<LoadingSpinner className="w-8 h-8" />
|
|
@@ -208,8 +298,31 @@ export function RegisterPage() {
|
|
|
208
298
|
}
|
|
209
299
|
|
|
210
300
|
const isInvite = !!inviteToken && !!invitePreview
|
|
211
|
-
const showDomainMatchPrompt = domainMatchAccountId && domainMatchChoice === null && useType === 'company'
|
|
301
|
+
const showDomainMatchPrompt = domainMatchAccountId && domainMatchChoice === null && useType === 'company' && !isFirstUser
|
|
212
302
|
const effectiveEmail = isInvite ? invitePreview.email : email
|
|
303
|
+
const showUseTypeToggle = !isInvite && !isFirstUser
|
|
304
|
+
|
|
305
|
+
// Header text based on scenario
|
|
306
|
+
let headerTitle = 'Create your account'
|
|
307
|
+
let headerSubtitle: React.ReactNode = null
|
|
308
|
+
|
|
309
|
+
if (isFirstUser) {
|
|
310
|
+
headerTitle = 'Create system administrator account'
|
|
311
|
+
headerSubtitle = (
|
|
312
|
+
<p className="mt-2 text-center text-sm text-slate-500">
|
|
313
|
+
You're the first user. This account will have full system access.
|
|
314
|
+
</p>
|
|
315
|
+
)
|
|
316
|
+
} else if (isInvite) {
|
|
317
|
+
headerTitle = `Join ${invitePreview.account_name ?? 'the team'}`
|
|
318
|
+
if (invitePreview.role_name) {
|
|
319
|
+
headerSubtitle = (
|
|
320
|
+
<p className="mt-2 text-center text-sm text-slate-500">
|
|
321
|
+
You've been invited as <span className="font-medium">{invitePreview.role_name}</span>
|
|
322
|
+
</p>
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
213
326
|
|
|
214
327
|
return (
|
|
215
328
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
@@ -221,19 +334,17 @@ export function RegisterPage() {
|
|
|
221
334
|
<span className="text-white font-bold text-xl">S</span>
|
|
222
335
|
</div>
|
|
223
336
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-slate-900">
|
|
224
|
-
{
|
|
337
|
+
{headerTitle}
|
|
225
338
|
</h2>
|
|
226
|
-
{
|
|
227
|
-
|
|
228
|
-
|
|
339
|
+
{headerSubtitle}
|
|
340
|
+
{!isFirstUser && (
|
|
341
|
+
<p className="mt-2 text-center text-sm text-slate-600">
|
|
342
|
+
Already have an account?{' '}
|
|
343
|
+
<Link to="/login" className="font-medium text-blue-600 hover:text-blue-500">
|
|
344
|
+
Sign in
|
|
345
|
+
</Link>
|
|
229
346
|
</p>
|
|
230
347
|
)}
|
|
231
|
-
<p className="mt-2 text-center text-sm text-slate-600">
|
|
232
|
-
Already have an account?{' '}
|
|
233
|
-
<Link to="/login" className="font-medium text-blue-600 hover:text-blue-500">
|
|
234
|
-
Sign in
|
|
235
|
-
</Link>
|
|
236
|
-
</p>
|
|
237
348
|
</div>
|
|
238
349
|
|
|
239
350
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
@@ -244,8 +355,8 @@ export function RegisterPage() {
|
|
|
244
355
|
)}
|
|
245
356
|
|
|
246
357
|
<div className="space-y-4">
|
|
247
|
-
{/* Personal / Company toggle — only for walk-up */}
|
|
248
|
-
{
|
|
358
|
+
{/* Personal / Company toggle — only for walk-up, not first user */}
|
|
359
|
+
{showUseTypeToggle && (
|
|
249
360
|
<div>
|
|
250
361
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
251
362
|
Account type
|
|
@@ -343,7 +454,7 @@ export function RegisterPage() {
|
|
|
343
454
|
)}
|
|
344
455
|
|
|
345
456
|
{/* Company name — shown for company type when not joining existing */}
|
|
346
|
-
{!isInvite && useType === 'company' && domainMatchChoice !== 'join' && (
|
|
457
|
+
{!isInvite && useType === 'company' && domainMatchChoice !== 'join' && !isFirstUser && (
|
|
347
458
|
<div>
|
|
348
459
|
<label htmlFor="companyName" className="block text-sm font-medium text-slate-700">
|
|
349
460
|
Company name
|
|
@@ -402,18 +513,19 @@ export function RegisterPage() {
|
|
|
402
513
|
|
|
403
514
|
<button
|
|
404
515
|
type="submit"
|
|
405
|
-
disabled={isLoading
|
|
516
|
+
disabled={isLoading}
|
|
406
517
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent rounded-[5px] shadow-sm text-sm font-medium text-white bg-slate-900 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
407
518
|
>
|
|
408
519
|
{isLoading ? (
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
520
|
+
<LoadingSpinner className="w-5 h-5" />
|
|
521
|
+
) : isFirstUser ? (
|
|
522
|
+
'Create System Admin Account'
|
|
523
|
+
) : isInvite ? (
|
|
524
|
+
'Accept Invite'
|
|
413
525
|
) : domainMatchChoice === 'join' ? (
|
|
414
|
-
'Request
|
|
526
|
+
'Request to Join'
|
|
415
527
|
) : (
|
|
416
|
-
'Create
|
|
528
|
+
'Create Account'
|
|
417
529
|
)}
|
|
418
530
|
</button>
|
|
419
531
|
</form>
|
package/config/vite.config.ts
CHANGED
|
@@ -10,6 +10,10 @@ export default defineConfig({
|
|
|
10
10
|
|
|
11
11
|
// Root directory for the assembled app
|
|
12
12
|
root: path.resolve(__dirname, '../.assembled/src'),
|
|
13
|
+
|
|
14
|
+
// Public directory for static files copied to dist
|
|
15
|
+
// Only spine.config.json is exposed publicly from project root
|
|
16
|
+
publicDir: path.resolve(__dirname, '../public'),
|
|
13
17
|
|
|
14
18
|
// Build configuration
|
|
15
19
|
build: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-framework",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.56",
|
|
4
4
|
"description": "Multi-tenant, modular application platform for modern SaaS systems",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"scripts/",
|
|
12
12
|
"config/",
|
|
13
13
|
"netlify.toml",
|
|
14
|
+
"spine.config.json",
|
|
14
15
|
".framework/cli/bin.js",
|
|
15
16
|
".framework/cli/",
|
|
16
17
|
".framework/docs/",
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
".framework/migrations/004_enhancements.sql",
|
|
22
23
|
".framework/migrations/005_auth_user_trigger.sql",
|
|
23
24
|
".framework/migrations/006_fix_current_actor_id.sql",
|
|
25
|
+
".framework/migrations/016_invites.sql",
|
|
24
26
|
".framework/public/",
|
|
25
27
|
".framework/scripts/",
|
|
26
28
|
".framework/seeds/",
|
|
@@ -61,6 +61,13 @@ if [ -f "$PROJECT_ROOT/.framework/public/_redirects" ]; then
|
|
|
61
61
|
echo " ✓ Core: _redirects copied to Vite-served directory for Netlify dev"
|
|
62
62
|
fi
|
|
63
63
|
|
|
64
|
+
# 3.6. Copy spine.config.json to public directory for runtime access
|
|
65
|
+
if [ -f "$PROJECT_ROOT/spine.config.json" ]; then
|
|
66
|
+
mkdir -p "$TARGET_SRC_DIR/public"
|
|
67
|
+
cp "$PROJECT_ROOT/spine.config.json" "$TARGET_SRC_DIR/public/spine.config.json"
|
|
68
|
+
echo " ✓ Config: spine.config.json copied to public/"
|
|
69
|
+
fi
|
|
70
|
+
|
|
64
71
|
# 6. Overlay custom src (overrides + additions)
|
|
65
72
|
if [ -d "$CUSTOM_SRC_DIR" ]; then
|
|
66
73
|
# Copy files and subdirectories
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./.framework/schemas/spine.config.schema.json",
|
|
3
|
+
"registration": {
|
|
4
|
+
"default_app": "portal",
|
|
5
|
+
"default_role": "member",
|
|
6
|
+
"allow_url_override": true,
|
|
7
|
+
"apps": {
|
|
8
|
+
"portal": {
|
|
9
|
+
"role": "member",
|
|
10
|
+
"path": "/portal",
|
|
11
|
+
"enabled": true
|
|
12
|
+
},
|
|
13
|
+
"cortex": {
|
|
14
|
+
"role": "support",
|
|
15
|
+
"path": "/cortex",
|
|
16
|
+
"enabled": true
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|