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.
- package/.framework/cli/commands/dev.ts +19 -0
- package/.framework/cli/commands/install-app.ts +89 -1
- package/.framework/functions/auth.ts +2 -0
- package/.framework/functions/invites.ts +655 -0
- package/.framework/migrations/005_auth_user_trigger.sql +52 -14
- package/.framework/migrations/016_invites.sql +27 -0
- package/.framework/src/App.tsx +6 -0
- package/.framework/src/apps/admin/index.tsx +6 -0
- package/.framework/src/components/admin/AdminSidebar.tsx +8 -1
- package/.framework/src/components/shared/InviteManager.tsx +352 -0
- package/.framework/src/pages/admin/InvitesPage.tsx +33 -0
- package/.framework/src/pages/auth/PendingApprovalPage.tsx +54 -0
- package/.framework/src/pages/auth/RegisterPage.tsx +433 -61
- package/.framework/src/types/auth.ts +2 -0
- 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
|
@@ -4,42 +4,213 @@
|
|
|
4
4
|
* @layer frontend-page
|
|
5
5
|
* @stability stable
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
337
|
+
{headerTitle}
|
|
68
338
|
</h2>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
<
|
|
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={
|
|
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-
|
|
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={
|
|
118
|
-
onChange={
|
|
119
|
-
|
|
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={
|
|
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-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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
|
+
}
|