spine-framework 0.3.54 → 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.
@@ -4,42 +4,213 @@
4
4
  * @layer frontend-page
5
5
  * @stability stable
6
6
  *
7
- * Self-registration page. Renders a centred card with email/password/name
8
- * inputs. On submit, calls Supabase signUp and navigates to /login with
9
- * a confirmation message.
7
+ * Registration page. Handles multiple paths:
8
+ *
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.
13
+ * On submit: Supabase signUp → auth trigger resolves stub → active.
14
+ *
15
+ * 3. Walk-up personal — new account named after the person.
16
+ * On submit: Supabase signUp → complete-registration(new_account).
17
+ *
18
+ * 4. Walk-up company, no domain match — new account with company name.
19
+ * On submit: Supabase signUp → complete-registration(new_account).
20
+ *
21
+ * 5. Walk-up company, domain match — user chooses join or create new.
22
+ * Join: Supabase signUp → complete-registration(join_request) → pending.
23
+ * Create new: same as path 3.
24
+ *
25
+ * URL params:
26
+ * ?app=portal|cortex — override default registration app
27
+ * ?role=member|support — override default role
28
+ *
29
+ * @seeAlso functions/invites.ts (check-domain, validate-token, complete-registration)
30
+ * @seeAlso migrations/005_auth_user_trigger.sql (invite stub resolution)
31
+ * @seeAlso src/pages/auth/PendingApprovalPage.tsx
10
32
  */
11
33
 
12
- import React, { useState } from 'react'
13
- import { Link, useNavigate } from 'react-router-dom'
14
- import { useAuth } from '../../contexts/AuthContext'
34
+ import React, { useState, useEffect, useCallback } from 'react'
35
+ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
15
36
  import { LoadingSpinner } from '../../components/ui/LoadingSpinner'
16
37
  import { supabase } from '../../lib/supabase'
17
38
 
39
+ // ─── TYPES ────────────────────────────────────────────────────────────────────
40
+
41
+ type UseType = 'personal' | 'company'
42
+ type DomainMatchChoice = 'join' | 'create_new' | null
43
+
44
+ interface InvitePreview {
45
+ email: string
46
+ account_name: string | null
47
+ role_name: string | null
48
+ kind: string
49
+ }
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
+
62
+ // ─── HELPERS ──────────────────────────────────────────────────────────────────
63
+
64
+ async function callInvitesApi(action: string, params: Record<string, any> = {}, method: 'GET' | 'POST' = 'GET') {
65
+ const base = '/.netlify/functions/invites'
66
+ if (method === 'GET') {
67
+ const qs = new URLSearchParams({ action, ...params }).toString()
68
+ const res = await fetch(`${base}?${qs}`)
69
+ return res.json()
70
+ }
71
+ const res = await fetch(`${base}?action=${action}`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify(params),
75
+ })
76
+ return res.json()
77
+ }
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
+
127
+ // ─── COMPONENT ────────────────────────────────────────────────────────────────
128
+
18
129
  export function RegisterPage() {
130
+ const [searchParams] = useSearchParams()
131
+ const navigate = useNavigate()
132
+ const inviteToken = searchParams.get('t')
133
+
134
+ // Registration config
135
+ const [regConfig, setRegConfig] = useState<RegistrationConfig | null>(null)
136
+ const [isFirstUser, setIsFirstUser] = useState<boolean | null>(null)
137
+
138
+ // Invite preview (when token present)
139
+ const [invitePreview, setInvitePreview] = useState<InvitePreview | null>(null)
140
+ const [tokenError, setTokenError] = useState('')
141
+ const [tokenLoading, setTokenLoading] = useState(!!inviteToken)
142
+
143
+ // Form fields
144
+ const [fullName, setFullName] = useState('')
19
145
  const [email, setEmail] = useState('')
146
+ const [companyName, setCompanyName] = useState('')
20
147
  const [password, setPassword] = useState('')
21
- const [fullName, setFullName] = useState('')
148
+
149
+ // UX state
150
+ const [useType, setUseType] = useState<UseType>('personal')
151
+ const [domainMatchAccountId, setDomainMatchAccountId] = useState<string | null>(null)
152
+ const [domainMatchChoice, setDomainMatchChoice] = useState<DomainMatchChoice>(null)
153
+ const [domainCheckLoading, setDomainCheckLoading] = useState(false)
154
+
22
155
  const [error, setError] = useState('')
23
- const [success, setSuccess] = useState('')
24
156
  const [isLoading, setIsLoading] = useState(false)
25
157
 
26
- const navigate = useNavigate()
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 ───────────────────────────────────────────────────
171
+ useEffect(() => {
172
+ if (!inviteToken) return
173
+ ;(async () => {
174
+ setTokenLoading(true)
175
+ const result = await callInvitesApi('validate-token', { token: inviteToken })
176
+ setTokenLoading(false)
177
+ if (result.error) {
178
+ setTokenError(result.error)
179
+ } else {
180
+ setInvitePreview(result.data)
181
+ setEmail(result.data.email)
182
+ }
183
+ })()
184
+ }, [inviteToken])
185
+
186
+ // ── Domain check on email blur ─────────────────────────────────────────────
187
+ const handleEmailBlur = useCallback(async () => {
188
+ if (!email || !email.includes('@') || inviteToken || useType === 'personal' || isFirstUser) return
189
+
190
+ setDomainCheckLoading(true)
191
+ setDomainMatchAccountId(null)
192
+ setDomainMatchChoice(null)
27
193
 
194
+ const result = await callInvitesApi('check-domain', { email })
195
+ setDomainCheckLoading(false)
196
+
197
+ if (result.data?.match) {
198
+ setDomainMatchAccountId('__matched__')
199
+ }
200
+ }, [email, inviteToken, useType, isFirstUser])
201
+
202
+ // ── Submit ─────────────────────────────────────────────────────────────────
28
203
  const handleSubmit = async (e: React.FormEvent) => {
29
204
  e.preventDefault()
30
205
  setError('')
31
- setSuccess('')
32
206
  setIsLoading(true)
33
207
 
34
208
  try {
35
- const { error: signUpError } = await supabase.auth.signUp({
36
- email,
209
+ // 1. Supabase auth signup
210
+ const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
211
+ email: invitePreview?.email ?? email,
37
212
  password,
38
- options: {
39
- data: {
40
- full_name: fullName,
41
- },
42
- },
213
+ options: { data: { full_name: fullName } },
43
214
  })
44
215
 
45
216
  if (signUpError) {
@@ -47,8 +218,53 @@ export function RegisterPage() {
47
218
  return
48
219
  }
49
220
 
50
- setSuccess('Account created! Check your email for a confirmation link.')
51
- setTimeout(() => navigate('/login'), 3000)
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
229
+ if (inviteToken) {
230
+ navigate('/', { replace: true })
231
+ return
232
+ }
233
+
234
+ // 4. Walk-up paths — call complete-registration
235
+ const resolvedEmail = email
236
+
237
+ if (domainMatchChoice === 'join') {
238
+ // Join existing account as access_request
239
+ const joinResult = await callInvitesApi('complete-registration', {
240
+ path: 'join_request',
241
+ email: resolvedEmail,
242
+ }, 'POST')
243
+
244
+ if (joinResult.error) {
245
+ setError(joinResult.error)
246
+ return
247
+ }
248
+ navigate('/pending-approval', { replace: true })
249
+ return
250
+ }
251
+
252
+ // New account (personal or company create_new)
253
+ const accountName = useType === 'personal' ? fullName : companyName
254
+ const newResult = await callInvitesApi('complete-registration', {
255
+ path: 'new_account',
256
+ email: resolvedEmail,
257
+ account_name: accountName,
258
+ }, 'POST')
259
+
260
+ if (newResult.error) {
261
+ setError(newResult.error)
262
+ return
263
+ }
264
+
265
+ // Navigate to configured post-registration destination
266
+ const redirectPath = getPostRegistrationRedirect(regConfig, searchParams, false)
267
+ navigate(redirectPath, { replace: true })
52
268
  } catch (err: any) {
53
269
  setError(err.message || 'Registration failed')
54
270
  } finally {
@@ -56,106 +272,262 @@ export function RegisterPage() {
56
272
  }
57
273
  }
58
274
 
275
+ // ── Loading states ───────────────────────────────────────────────────────────
276
+ if (tokenLoading || isFirstUser === null) {
277
+ return (
278
+ <div className="min-h-screen flex items-center justify-center bg-slate-50">
279
+ <LoadingSpinner className="w-8 h-8" />
280
+ </div>
281
+ )
282
+ }
283
+
284
+ if (tokenError) {
285
+ return (
286
+ <div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
287
+ <div className="max-w-md w-full text-center space-y-4">
288
+ <div className="rounded-md bg-red-50 p-6">
289
+ <p className="text-red-800 font-medium">Invite link invalid</p>
290
+ <p className="text-red-700 text-sm mt-1">{tokenError}</p>
291
+ </div>
292
+ <Link to="/login" className="text-sm text-slate-600 hover:text-slate-900">
293
+ Back to login
294
+ </Link>
295
+ </div>
296
+ </div>
297
+ )
298
+ }
299
+
300
+ const isInvite = !!inviteToken && !!invitePreview
301
+ const showDomainMatchPrompt = domainMatchAccountId && domainMatchChoice === null && useType === 'company' && !isFirstUser
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
+ }
326
+
59
327
  return (
60
328
  <div className="min-h-screen flex items-center justify-center bg-slate-50 py-12 px-4 sm:px-6 lg:px-8">
61
329
  <div className="max-w-md w-full space-y-8">
330
+
331
+ {/* Header */}
62
332
  <div>
63
333
  <div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-slate-900">
64
334
  <span className="text-white font-bold text-xl">S</span>
65
335
  </div>
66
336
  <h2 className="mt-6 text-center text-3xl font-extrabold text-slate-900">
67
- Create your account
337
+ {headerTitle}
68
338
  </h2>
69
- <p className="mt-2 text-center text-sm text-slate-600">
70
- Already have an account?{' '}
71
- <Link to="/login" className="font-medium text-accent-blue hover:text-accent-blue-light">
72
- Sign in
73
- </Link>
74
- </p>
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>
346
+ </p>
347
+ )}
75
348
  </div>
76
349
 
77
350
  <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
78
351
  {error && (
79
352
  <div className="rounded-md bg-red-50 p-4">
80
- <div className="text-sm text-red-800">{error}</div>
81
- </div>
82
- )}
83
-
84
- {success && (
85
- <div className="rounded-md bg-green-50 p-4">
86
- <div className="text-sm text-green-800">{success}</div>
353
+ <p className="text-sm text-red-800">{error}</p>
87
354
  </div>
88
355
  )}
89
356
 
90
357
  <div className="space-y-4">
358
+ {/* Personal / Company toggle — only for walk-up, not first user */}
359
+ {showUseTypeToggle && (
360
+ <div>
361
+ <label className="block text-sm font-medium text-slate-700 mb-2">
362
+ Account type
363
+ </label>
364
+ <div className="flex gap-3">
365
+ {(['personal', 'company'] as UseType[]).map(type => (
366
+ <button
367
+ key={type}
368
+ type="button"
369
+ onClick={() => {
370
+ setUseType(type)
371
+ setDomainMatchAccountId(null)
372
+ setDomainMatchChoice(null)
373
+ }}
374
+ className={`flex-1 py-2 px-4 rounded-[5px] border text-sm font-medium transition-colors ${
375
+ useType === type
376
+ ? 'bg-slate-900 text-white border-slate-900'
377
+ : 'bg-white text-slate-700 border-slate-300 hover:border-slate-400'
378
+ }`}
379
+ >
380
+ {type === 'personal' ? 'Personal' : 'Company'}
381
+ </button>
382
+ ))}
383
+ </div>
384
+ </div>
385
+ )}
386
+
387
+ {/* Full name */}
91
388
  <div>
92
389
  <label htmlFor="fullName" className="block text-sm font-medium text-slate-700">
93
390
  Full name
94
391
  </label>
95
392
  <input
96
393
  id="fullName"
97
- name="fullName"
98
394
  type="text"
99
395
  required
100
396
  value={fullName}
101
- onChange={(e) => setFullName(e.target.value)}
102
- className="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-[5px] shadow-sm placeholder-slate-400 focus:outline-none focus:ring-accent-blue focus:border-accent-blue sm:text-sm"
397
+ onChange={e => setFullName(e.target.value)}
398
+ className="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-[5px] shadow-sm placeholder-slate-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
103
399
  placeholder="Enter your full name"
104
400
  />
105
401
  </div>
106
402
 
403
+ {/* Email — locked when invite */}
107
404
  <div>
108
405
  <label htmlFor="email" className="block text-sm font-medium text-slate-700">
109
406
  Email address
110
407
  </label>
111
408
  <input
112
409
  id="email"
113
- name="email"
114
410
  type="email"
115
- autoComplete="email"
116
411
  required
117
- value={email}
118
- onChange={(e) => setEmail(e.target.value)}
119
- className="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-[5px] shadow-sm placeholder-slate-400 focus:outline-none focus:ring-accent-blue focus:border-accent-blue sm:text-sm"
412
+ value={effectiveEmail}
413
+ onChange={e => !isInvite && setEmail(e.target.value)}
414
+ onBlur={handleEmailBlur}
415
+ readOnly={isInvite}
416
+ className={`mt-1 block w-full px-3 py-2 border rounded-[5px] shadow-sm placeholder-slate-400 focus:outline-none sm:text-sm ${
417
+ isInvite
418
+ ? 'bg-slate-50 border-slate-200 text-slate-500 cursor-not-allowed'
419
+ : 'border-slate-300 focus:ring-blue-500 focus:border-blue-500'
420
+ }`}
120
421
  placeholder="Enter your email"
121
422
  />
423
+ {domainCheckLoading && (
424
+ <p className="mt-1 text-xs text-slate-500">Checking domain...</p>
425
+ )}
122
426
  </div>
123
427
 
428
+ {/* Domain match prompt */}
429
+ {showDomainMatchPrompt && (
430
+ <div className="rounded-md bg-blue-50 border border-blue-200 p-4 space-y-3">
431
+ <p className="text-sm text-blue-800 font-medium">
432
+ An account for this domain already exists.
433
+ </p>
434
+ <p className="text-sm text-blue-700">
435
+ Would you like to request to join the existing account, or create a new one?
436
+ </p>
437
+ <div className="flex gap-2">
438
+ <button
439
+ type="button"
440
+ onClick={() => setDomainMatchChoice('join')}
441
+ className="flex-1 py-2 px-3 rounded-[5px] bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
442
+ >
443
+ Request to join
444
+ </button>
445
+ <button
446
+ type="button"
447
+ onClick={() => setDomainMatchChoice('create_new')}
448
+ className="flex-1 py-2 px-3 rounded-[5px] border border-blue-300 text-blue-700 text-sm font-medium hover:bg-blue-100"
449
+ >
450
+ Create new account
451
+ </button>
452
+ </div>
453
+ </div>
454
+ )}
455
+
456
+ {/* Company name — shown for company type when not joining existing */}
457
+ {!isInvite && useType === 'company' && domainMatchChoice !== 'join' && !isFirstUser && (
458
+ <div>
459
+ <label htmlFor="companyName" className="block text-sm font-medium text-slate-700">
460
+ Company name
461
+ </label>
462
+ <input
463
+ id="companyName"
464
+ type="text"
465
+ required={useType === 'company'}
466
+ value={companyName}
467
+ onChange={e => setCompanyName(e.target.value)}
468
+ className="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-[5px] shadow-sm placeholder-slate-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
469
+ placeholder="Enter your company name"
470
+ />
471
+ </div>
472
+ )}
473
+
474
+ {/* Invite account name — locked */}
475
+ {isInvite && invitePreview.account_name && (
476
+ <div>
477
+ <label className="block text-sm font-medium text-slate-700">Account</label>
478
+ <input
479
+ type="text"
480
+ readOnly
481
+ value={invitePreview.account_name}
482
+ className="mt-1 block w-full px-3 py-2 border border-slate-200 rounded-[5px] bg-slate-50 text-slate-500 cursor-not-allowed sm:text-sm"
483
+ />
484
+ </div>
485
+ )}
486
+
487
+ {/* Password */}
124
488
  <div>
125
489
  <label htmlFor="password" className="block text-sm font-medium text-slate-700">
126
490
  Password
127
491
  </label>
128
492
  <input
129
493
  id="password"
130
- name="password"
131
494
  type="password"
132
- autoComplete="new-password"
133
495
  required
134
496
  minLength={6}
135
497
  value={password}
136
- onChange={(e) => setPassword(e.target.value)}
137
- className="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-[5px] shadow-sm placeholder-slate-400 focus:outline-none focus:ring-accent-blue focus:border-accent-blue sm:text-sm"
498
+ onChange={e => setPassword(e.target.value)}
499
+ className="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-[5px] shadow-sm placeholder-slate-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
138
500
  placeholder="Choose a password (min 6 characters)"
139
501
  />
140
502
  </div>
141
503
  </div>
142
504
 
143
- <div>
144
- <button
145
- type="submit"
146
- disabled={isLoading}
147
- 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-accent-blue disabled:opacity-50 disabled:cursor-not-allowed"
148
- >
149
- {isLoading ? (
150
- <>
151
- <LoadingSpinner size="sm" />
152
- <span className="ml-2">Creating account...</span>
153
- </>
154
- ) : (
155
- 'Create account'
156
- )}
157
- </button>
158
- </div>
505
+ {/* Join request info banner */}
506
+ {domainMatchChoice === 'join' && (
507
+ <div className="rounded-md bg-amber-50 border border-amber-200 p-3">
508
+ <p className="text-sm text-amber-800">
509
+ Your registration will be submitted for approval. You'll gain access once an administrator approves your request.
510
+ </p>
511
+ </div>
512
+ )}
513
+
514
+ <button
515
+ type="submit"
516
+ disabled={isLoading}
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"
518
+ >
519
+ {isLoading ? (
520
+ <LoadingSpinner className="w-5 h-5" />
521
+ ) : isFirstUser ? (
522
+ 'Create System Admin Account'
523
+ ) : isInvite ? (
524
+ 'Accept Invite'
525
+ ) : domainMatchChoice === 'join' ? (
526
+ 'Request to Join'
527
+ ) : (
528
+ 'Create Account'
529
+ )}
530
+ </button>
159
531
  </form>
160
532
  </div>
161
533
  </div>
@@ -43,6 +43,8 @@ export interface User {
43
43
  roles: string[] // Simplified for now
44
44
  permissions: string[] // Simplified for now
45
45
  is_system_admin?: boolean
46
+ /** people.status — 'active' | 'pending' | 'invited' | 'inactive' */
47
+ status?: string
46
48
  accessible_accounts?: Account[]
47
49
  }
48
50
 
@@ -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.54",
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
+ }