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.
- package/.framework/cli/commands/dev.ts +19 -0
- package/.framework/cli/commands/install-app.ts +89 -1
- package/.framework/functions/_shared/index.ts +1 -1
- package/.framework/functions/_shared/middleware.ts +90 -0
- package/.framework/functions/invites.ts +29 -8
- 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
|
+
}
|
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
289
|
+
const { data: roles } = await adminDb
|
|
272
290
|
.from('roles')
|
|
273
291
|
.select('id')
|
|
274
292
|
.eq('slug', 'member')
|
|
275
|
-
.
|
|
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 =
|
|
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
|
|
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.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
|
+
}
|