spine-framework 0.3.55 → 0.3.57

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.
@@ -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
+ }
@@ -76,7 +76,7 @@ export type { RequestContext, HandlerFunction, HandlerResult } from './middlewar
76
76
  * createHandler — wraps a handler function with auth, principal resolution, and audit.
77
77
  * Use this when writing Netlify function handlers.
78
78
  */
79
- export { createHandler, requireUserContext, requireSystemContextWithAudit, json, error as errorResponse, cors } from './middleware'
79
+ export { createHandler, createPublicHandler, requireUserContext, requireSystemContextWithAudit, json, error as errorResponse, cors } from './middleware'
80
80
 
81
81
  // ============================================
82
82
  // Identity — Principal model
@@ -274,6 +274,96 @@ export function createHandler(handler: HandlerFunction) {
274
274
  }
275
275
  }
276
276
 
277
+ // ─── PUBLIC HANDLER FACTORY ────────────────────────────────────────────────────
278
+
279
+ /**
280
+ * Wraps a handler function without requiring authentication.
281
+ * Used for public endpoints like check-first-user, check-domain, validate-token.
282
+ *
283
+ * Similar to createHandler but allows anonymous principals.
284
+ */
285
+ export function createPublicHandler(handler: HandlerFunction) {
286
+ return async (event: any, context: any) => {
287
+ // Detect nested call: if event is already a RequestContext with a principal,
288
+ // skip event parsing and call the raw handler directly
289
+ if (event && event.requestId && event.principal) {
290
+ return handler(event, context)
291
+ }
292
+
293
+ const requestId = crypto.randomUUID()
294
+ const startTime = Date.now()
295
+
296
+ try {
297
+ // Parse headers
298
+ const appId = event.headers?.['x-app-id'] || event.headers?.['X-App-Id']
299
+
300
+ // Parse query string parameters
301
+ const queryParams: Record<string, string> = {}
302
+ if (event.queryStringParameters) {
303
+ Object.assign(queryParams, event.queryStringParameters)
304
+ }
305
+ if (!queryParams.method && event.httpMethod) {
306
+ queryParams.method = event.httpMethod
307
+ }
308
+
309
+ // Resolve principal - may be anonymous for public endpoints
310
+ const principal = await resolvePrincipal(event)
311
+
312
+ // Get RLS-scoped database client based on principal type
313
+ // For anonymous users, this returns adminDb (service role)
314
+ const ctxDb = getPrincipalDb(principal)
315
+
316
+ // Build request context
317
+ const ctx: RequestContext = {
318
+ requestId,
319
+ principal,
320
+ db: ctxDb,
321
+ accountId: principal?.accountId || null,
322
+ appId: appId || null,
323
+ query: queryParams,
324
+ requestPath: event.path || '/',
325
+ }
326
+
327
+ // Parse body
328
+ let body = null
329
+ if (event.body) {
330
+ try {
331
+ body = JSON.parse(event.body)
332
+ } catch (e) {
333
+ return error('Invalid JSON in request body', 400)
334
+ }
335
+ }
336
+ if (ctx.query.id) {
337
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
338
+ body = {}
339
+ }
340
+ if (!('id' in body)) {
341
+ body.id = ctx.query.id
342
+ }
343
+ }
344
+
345
+ // Execute handler
346
+ const result = await handler(ctx, body)
347
+ const durationMs = Date.now() - startTime
348
+
349
+ // Return success response
350
+ return json({
351
+ data: result,
352
+ error: null,
353
+ meta: {
354
+ requestId,
355
+ duration: durationMs
356
+ }
357
+ })
358
+
359
+ } catch (err: any) {
360
+ console.error(`[${requestId}] Public handler error:`, err)
361
+ const status = typeof err.statusCode === 'number' ? err.statusCode : 500
362
+ return error(err.message || 'Internal server error', status)
363
+ }
364
+ }
365
+ }
366
+
277
367
  // ─── RESPONSE HELPERS ────────────────────────────────────────────────────────
278
368
 
279
369
  /**
@@ -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 |
@@ -51,7 +52,7 @@
51
52
  * @seeAlso src/components/shared/InviteManager.tsx (UI)
52
53
  */
53
54
 
54
- import { createHandler } from './_shared/middleware'
55
+ import { createHandler, createPublicHandler } from './_shared/middleware'
55
56
  import { adminDb } from './_shared/db'
56
57
  import { emitLog } from './_shared/audit'
57
58
 
@@ -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
  /**
@@ -222,13 +239,14 @@ export const completeRegistration = createHandler(async (ctx, body) => {
222
239
  .single()
223
240
 
224
241
  // Resolve member_admin role (fall back to member if not seeded yet)
225
- const { data: role } = await adminDb
242
+ const { data: roles } = await adminDb
226
243
  .from('roles')
227
244
  .select('id')
228
245
  .in('slug', ['member_admin', 'member'])
229
246
  .order('slug') // member_admin sorts before member
230
247
  .limit(1)
231
- .single()
248
+
249
+ const role = roles?.[0]
232
250
 
233
251
  // Create account
234
252
  const slug = generateAccountSlug(account_name)
@@ -268,11 +286,13 @@ export const completeRegistration = createHandler(async (ctx, body) => {
268
286
  if (!target_account_id) throw new Error('target_account_id is required for join_request')
269
287
 
270
288
  // Resolve member role
271
- const { data: role } = await adminDb
289
+ const { data: roles } = await adminDb
272
290
  .from('roles')
273
291
  .select('id')
274
292
  .eq('slug', 'member')
275
- .single()
293
+ .limit(1)
294
+
295
+ const role = roles?.[0]
276
296
 
277
297
  // Resolve type_id
278
298
  const { data: personType } = await adminDb
@@ -616,15 +636,16 @@ export const deny = createHandler(async (ctx, body) => {
616
636
 
617
637
  // ─── MAIN HANDLER ────────────────────────────────────────────────────────────
618
638
 
619
- export const handler = createHandler(async (ctx, body) => {
639
+ export const handler = createPublicHandler(async (ctx, body) => {
620
640
  const action = ctx.query?.action
621
641
 
622
642
  switch (action) {
623
- // Public
643
+ // Public endpoints (no auth required)
644
+ case 'check-first-user': return await checkFirstUser(ctx, body)
624
645
  case 'check-domain': return await checkDomain(ctx, body)
625
646
  case 'validate-token': return await validateToken(ctx, body)
626
647
  case 'complete-registration': return await completeRegistration(ctx, body)
627
- // Protected
648
+ // Protected endpoints (require auth)
628
649
  case 'create-invite': return await createInvite(ctx, body)
629
650
  case 'list-invites': return await listInvites(ctx, body)
630
651
  case 'revoke-invite': return await revokeInvite(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 four paths:
7
+ * Registration page. Handles multiple paths:
8
8
  *
9
- * 1. Invite link (?t=TOKEN) — email + account pre-filled and locked.
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
- * 2. Walk-up personal — new account named after the person.
15
+ * 3. Walk-up personal — new account named after the person.
13
16
  * On submit: Supabase signUp → complete-registration(new_account).
14
17
  *
15
- * 3. Walk-up company, no domain match — new account with company name.
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
- * 4. Walk-up company, domain match — user chooses join or create new.
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
- // ── Resolve invite token on mount ──────────────────────────────────────────
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. Invite path — trigger handles everything, navigate to login
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('/login?registered=1')
230
+ navigate('/', { replace: true })
143
231
  return
144
232
  }
145
233
 
146
- // 3. Walk-up paths — call complete-registration
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
- navigate('/login?registered=1')
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
- // ── Token loading / error states ───────────────────────────────────────────
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
- {isInvite ? `Join ${invitePreview.account_name ?? 'the team'}` : 'Create your account'}
337
+ {headerTitle}
225
338
  </h2>
226
- {isInvite && invitePreview.role_name && (
227
- <p className="mt-2 text-center text-sm text-slate-500">
228
- You've been invited as <span className="font-medium">{invitePreview.role_name}</span>
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
- {!isInvite && (
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 || (showDomainMatchPrompt ? false : false)}
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
- <LoadingSpinner size="sm" />
411
- <span className="ml-2">Creating account...</span>
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 access'
526
+ 'Request to Join'
415
527
  ) : (
416
- 'Create account'
528
+ 'Create Account'
417
529
  )}
418
530
  </button>
419
531
  </form>
@@ -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.55",
3
+ "version": "0.3.57",
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
+ }