spine-framework 0.3.54 → 0.3.55
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/functions/auth.ts +2 -0
- package/.framework/functions/invites.ts +637 -0
- package/.framework/migrations/005_auth_user_trigger.sql +52 -14
- 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 +315 -55
- package/.framework/src/types/auth.ts +2 -0
- package/package.json +1 -1
|
@@ -4,42 +4,132 @@
|
|
|
4
4
|
* @layer frontend-page
|
|
5
5
|
* @stability stable
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Registration page. Handles four paths:
|
|
8
|
+
*
|
|
9
|
+
* 1. Invite link (?t=TOKEN) — email + account pre-filled and locked.
|
|
10
|
+
* On submit: Supabase signUp → auth trigger resolves stub → active.
|
|
11
|
+
*
|
|
12
|
+
* 2. Walk-up personal — new account named after the person.
|
|
13
|
+
* On submit: Supabase signUp → complete-registration(new_account).
|
|
14
|
+
*
|
|
15
|
+
* 3. Walk-up company, no domain match — new account with company name.
|
|
16
|
+
* On submit: Supabase signUp → complete-registration(new_account).
|
|
17
|
+
*
|
|
18
|
+
* 4. Walk-up company, domain match — user chooses join or create new.
|
|
19
|
+
* Join: Supabase signUp → complete-registration(join_request) → pending.
|
|
20
|
+
* Create new: same as path 3.
|
|
21
|
+
*
|
|
22
|
+
* @seeAlso functions/invites.ts (check-domain, validate-token, complete-registration)
|
|
23
|
+
* @seeAlso migrations/005_auth_user_trigger.sql (invite stub resolution)
|
|
24
|
+
* @seeAlso src/pages/auth/PendingApprovalPage.tsx
|
|
10
25
|
*/
|
|
11
26
|
|
|
12
|
-
import React, { useState } from 'react'
|
|
13
|
-
import { Link, useNavigate } from 'react-router-dom'
|
|
14
|
-
import { useAuth } from '../../contexts/AuthContext'
|
|
27
|
+
import React, { useState, useEffect, useCallback } from 'react'
|
|
28
|
+
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
|
15
29
|
import { LoadingSpinner } from '../../components/ui/LoadingSpinner'
|
|
16
30
|
import { supabase } from '../../lib/supabase'
|
|
17
31
|
|
|
32
|
+
// ─── TYPES ────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
type UseType = 'personal' | 'company'
|
|
35
|
+
type DomainMatchChoice = 'join' | 'create_new' | null
|
|
36
|
+
|
|
37
|
+
interface InvitePreview {
|
|
38
|
+
email: string
|
|
39
|
+
account_name: string | null
|
|
40
|
+
role_name: string | null
|
|
41
|
+
kind: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
async function callInvitesApi(action: string, params: Record<string, any> = {}, method: 'GET' | 'POST' = 'GET') {
|
|
47
|
+
const base = '/.netlify/functions/invites'
|
|
48
|
+
if (method === 'GET') {
|
|
49
|
+
const qs = new URLSearchParams({ action, ...params }).toString()
|
|
50
|
+
const res = await fetch(`${base}?${qs}`)
|
|
51
|
+
return res.json()
|
|
52
|
+
}
|
|
53
|
+
const res = await fetch(`${base}?action=${action}`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify(params),
|
|
57
|
+
})
|
|
58
|
+
return res.json()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── COMPONENT ────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
18
63
|
export function RegisterPage() {
|
|
64
|
+
const [searchParams] = useSearchParams()
|
|
65
|
+
const navigate = useNavigate()
|
|
66
|
+
const inviteToken = searchParams.get('t')
|
|
67
|
+
|
|
68
|
+
// Invite preview (when token present)
|
|
69
|
+
const [invitePreview, setInvitePreview] = useState<InvitePreview | null>(null)
|
|
70
|
+
const [tokenError, setTokenError] = useState('')
|
|
71
|
+
const [tokenLoading, setTokenLoading] = useState(!!inviteToken)
|
|
72
|
+
|
|
73
|
+
// Form fields
|
|
74
|
+
const [fullName, setFullName] = useState('')
|
|
19
75
|
const [email, setEmail] = useState('')
|
|
76
|
+
const [companyName, setCompanyName] = useState('')
|
|
20
77
|
const [password, setPassword] = useState('')
|
|
21
|
-
|
|
78
|
+
|
|
79
|
+
// UX state
|
|
80
|
+
const [useType, setUseType] = useState<UseType>('personal')
|
|
81
|
+
const [domainMatchAccountId, setDomainMatchAccountId] = useState<string | null>(null)
|
|
82
|
+
const [domainMatchChoice, setDomainMatchChoice] = useState<DomainMatchChoice>(null)
|
|
83
|
+
const [domainCheckLoading, setDomainCheckLoading] = useState(false)
|
|
84
|
+
|
|
22
85
|
const [error, setError] = useState('')
|
|
23
|
-
const [success, setSuccess] = useState('')
|
|
24
86
|
const [isLoading, setIsLoading] = useState(false)
|
|
25
87
|
|
|
26
|
-
|
|
88
|
+
// ── Resolve invite token on mount ──────────────────────────────────────────
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!inviteToken) return
|
|
91
|
+
;(async () => {
|
|
92
|
+
setTokenLoading(true)
|
|
93
|
+
const result = await callInvitesApi('validate-token', { token: inviteToken })
|
|
94
|
+
setTokenLoading(false)
|
|
95
|
+
if (result.error) {
|
|
96
|
+
setTokenError(result.error)
|
|
97
|
+
} else {
|
|
98
|
+
setInvitePreview(result.data)
|
|
99
|
+
setEmail(result.data.email)
|
|
100
|
+
}
|
|
101
|
+
})()
|
|
102
|
+
}, [inviteToken])
|
|
103
|
+
|
|
104
|
+
// ── Domain check on email blur ─────────────────────────────────────────────
|
|
105
|
+
const handleEmailBlur = useCallback(async () => {
|
|
106
|
+
if (!email || !email.includes('@') || inviteToken || useType === 'personal') return
|
|
27
107
|
|
|
108
|
+
setDomainCheckLoading(true)
|
|
109
|
+
setDomainMatchAccountId(null)
|
|
110
|
+
setDomainMatchChoice(null)
|
|
111
|
+
|
|
112
|
+
const result = await callInvitesApi('check-domain', { email })
|
|
113
|
+
setDomainCheckLoading(false)
|
|
114
|
+
|
|
115
|
+
if (result.data?.match) {
|
|
116
|
+
// We signal a match but don't expose the account id here (fetched server-side on join)
|
|
117
|
+
setDomainMatchAccountId('__matched__')
|
|
118
|
+
}
|
|
119
|
+
}, [email, inviteToken, useType])
|
|
120
|
+
|
|
121
|
+
// ── Submit ─────────────────────────────────────────────────────────────────
|
|
28
122
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
29
123
|
e.preventDefault()
|
|
30
124
|
setError('')
|
|
31
|
-
setSuccess('')
|
|
32
125
|
setIsLoading(true)
|
|
33
126
|
|
|
34
127
|
try {
|
|
128
|
+
// 1. Supabase auth signup
|
|
35
129
|
const { error: signUpError } = await supabase.auth.signUp({
|
|
36
|
-
email,
|
|
130
|
+
email: invitePreview?.email ?? email,
|
|
37
131
|
password,
|
|
38
|
-
options: {
|
|
39
|
-
data: {
|
|
40
|
-
full_name: fullName,
|
|
41
|
-
},
|
|
42
|
-
},
|
|
132
|
+
options: { data: { full_name: fullName } },
|
|
43
133
|
})
|
|
44
134
|
|
|
45
135
|
if (signUpError) {
|
|
@@ -47,8 +137,44 @@ export function RegisterPage() {
|
|
|
47
137
|
return
|
|
48
138
|
}
|
|
49
139
|
|
|
50
|
-
|
|
51
|
-
|
|
140
|
+
// 2. Invite path — trigger handles everything, navigate to login
|
|
141
|
+
if (inviteToken) {
|
|
142
|
+
navigate('/login?registered=1')
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 3. Walk-up paths — call complete-registration
|
|
147
|
+
const resolvedEmail = email
|
|
148
|
+
|
|
149
|
+
if (domainMatchChoice === 'join') {
|
|
150
|
+
// Join existing account as access_request
|
|
151
|
+
const joinResult = await callInvitesApi('complete-registration', {
|
|
152
|
+
path: 'join_request',
|
|
153
|
+
email: resolvedEmail,
|
|
154
|
+
}, 'POST')
|
|
155
|
+
|
|
156
|
+
if (joinResult.error) {
|
|
157
|
+
setError(joinResult.error)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
navigate('/pending-approval')
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// New account (personal or company create_new)
|
|
165
|
+
const accountName = useType === 'personal' ? fullName : companyName
|
|
166
|
+
const newResult = await callInvitesApi('complete-registration', {
|
|
167
|
+
path: 'new_account',
|
|
168
|
+
email: resolvedEmail,
|
|
169
|
+
account_name: accountName,
|
|
170
|
+
}, 'POST')
|
|
171
|
+
|
|
172
|
+
if (newResult.error) {
|
|
173
|
+
setError(newResult.error)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
navigate('/login?registered=1')
|
|
52
178
|
} catch (err: any) {
|
|
53
179
|
setError(err.message || 'Registration failed')
|
|
54
180
|
} finally {
|
|
@@ -56,19 +182,55 @@ export function RegisterPage() {
|
|
|
56
182
|
}
|
|
57
183
|
}
|
|
58
184
|
|
|
185
|
+
// ── Token loading / error states ───────────────────────────────────────────
|
|
186
|
+
if (tokenLoading) {
|
|
187
|
+
return (
|
|
188
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
|
189
|
+
<LoadingSpinner className="w-8 h-8" />
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (tokenError) {
|
|
195
|
+
return (
|
|
196
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
197
|
+
<div className="max-w-md w-full text-center space-y-4">
|
|
198
|
+
<div className="rounded-md bg-red-50 p-6">
|
|
199
|
+
<p className="text-red-800 font-medium">Invite link invalid</p>
|
|
200
|
+
<p className="text-red-700 text-sm mt-1">{tokenError}</p>
|
|
201
|
+
</div>
|
|
202
|
+
<Link to="/login" className="text-sm text-slate-600 hover:text-slate-900">
|
|
203
|
+
Back to login
|
|
204
|
+
</Link>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const isInvite = !!inviteToken && !!invitePreview
|
|
211
|
+
const showDomainMatchPrompt = domainMatchAccountId && domainMatchChoice === null && useType === 'company'
|
|
212
|
+
const effectiveEmail = isInvite ? invitePreview.email : email
|
|
213
|
+
|
|
59
214
|
return (
|
|
60
215
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
61
216
|
<div className="max-w-md w-full space-y-8">
|
|
217
|
+
|
|
218
|
+
{/* Header */}
|
|
62
219
|
<div>
|
|
63
220
|
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-slate-900">
|
|
64
221
|
<span className="text-white font-bold text-xl">S</span>
|
|
65
222
|
</div>
|
|
66
223
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-slate-900">
|
|
67
|
-
Create your account
|
|
224
|
+
{isInvite ? `Join ${invitePreview.account_name ?? 'the team'}` : 'Create your account'}
|
|
68
225
|
</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>
|
|
229
|
+
</p>
|
|
230
|
+
)}
|
|
69
231
|
<p className="mt-2 text-center text-sm text-slate-600">
|
|
70
232
|
Already have an account?{' '}
|
|
71
|
-
<Link to="/login" className="font-medium text-
|
|
233
|
+
<Link to="/login" className="font-medium text-blue-600 hover:text-blue-500">
|
|
72
234
|
Sign in
|
|
73
235
|
</Link>
|
|
74
236
|
</p>
|
|
@@ -77,85 +239,183 @@ export function RegisterPage() {
|
|
|
77
239
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
78
240
|
{error && (
|
|
79
241
|
<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>
|
|
242
|
+
<p className="text-sm text-red-800">{error}</p>
|
|
87
243
|
</div>
|
|
88
244
|
)}
|
|
89
245
|
|
|
90
246
|
<div className="space-y-4">
|
|
247
|
+
{/* Personal / Company toggle — only for walk-up */}
|
|
248
|
+
{!isInvite && (
|
|
249
|
+
<div>
|
|
250
|
+
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
251
|
+
Account type
|
|
252
|
+
</label>
|
|
253
|
+
<div className="flex gap-3">
|
|
254
|
+
{(['personal', 'company'] as UseType[]).map(type => (
|
|
255
|
+
<button
|
|
256
|
+
key={type}
|
|
257
|
+
type="button"
|
|
258
|
+
onClick={() => {
|
|
259
|
+
setUseType(type)
|
|
260
|
+
setDomainMatchAccountId(null)
|
|
261
|
+
setDomainMatchChoice(null)
|
|
262
|
+
}}
|
|
263
|
+
className={`flex-1 py-2 px-4 rounded-[5px] border text-sm font-medium transition-colors ${
|
|
264
|
+
useType === type
|
|
265
|
+
? 'bg-slate-900 text-white border-slate-900'
|
|
266
|
+
: 'bg-white text-slate-700 border-slate-300 hover:border-slate-400'
|
|
267
|
+
}`}
|
|
268
|
+
>
|
|
269
|
+
{type === 'personal' ? 'Personal' : 'Company'}
|
|
270
|
+
</button>
|
|
271
|
+
))}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{/* Full name */}
|
|
91
277
|
<div>
|
|
92
278
|
<label htmlFor="fullName" className="block text-sm font-medium text-slate-700">
|
|
93
279
|
Full name
|
|
94
280
|
</label>
|
|
95
281
|
<input
|
|
96
282
|
id="fullName"
|
|
97
|
-
name="fullName"
|
|
98
283
|
type="text"
|
|
99
284
|
required
|
|
100
285
|
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-
|
|
286
|
+
onChange={e => setFullName(e.target.value)}
|
|
287
|
+
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
288
|
placeholder="Enter your full name"
|
|
104
289
|
/>
|
|
105
290
|
</div>
|
|
106
291
|
|
|
292
|
+
{/* Email — locked when invite */}
|
|
107
293
|
<div>
|
|
108
294
|
<label htmlFor="email" className="block text-sm font-medium text-slate-700">
|
|
109
295
|
Email address
|
|
110
296
|
</label>
|
|
111
297
|
<input
|
|
112
298
|
id="email"
|
|
113
|
-
name="email"
|
|
114
299
|
type="email"
|
|
115
|
-
autoComplete="email"
|
|
116
300
|
required
|
|
117
|
-
value={
|
|
118
|
-
onChange={
|
|
119
|
-
|
|
301
|
+
value={effectiveEmail}
|
|
302
|
+
onChange={e => !isInvite && setEmail(e.target.value)}
|
|
303
|
+
onBlur={handleEmailBlur}
|
|
304
|
+
readOnly={isInvite}
|
|
305
|
+
className={`mt-1 block w-full px-3 py-2 border rounded-[5px] shadow-sm placeholder-slate-400 focus:outline-none sm:text-sm ${
|
|
306
|
+
isInvite
|
|
307
|
+
? 'bg-slate-50 border-slate-200 text-slate-500 cursor-not-allowed'
|
|
308
|
+
: 'border-slate-300 focus:ring-blue-500 focus:border-blue-500'
|
|
309
|
+
}`}
|
|
120
310
|
placeholder="Enter your email"
|
|
121
311
|
/>
|
|
312
|
+
{domainCheckLoading && (
|
|
313
|
+
<p className="mt-1 text-xs text-slate-500">Checking domain...</p>
|
|
314
|
+
)}
|
|
122
315
|
</div>
|
|
123
316
|
|
|
317
|
+
{/* Domain match prompt */}
|
|
318
|
+
{showDomainMatchPrompt && (
|
|
319
|
+
<div className="rounded-md bg-blue-50 border border-blue-200 p-4 space-y-3">
|
|
320
|
+
<p className="text-sm text-blue-800 font-medium">
|
|
321
|
+
An account for this domain already exists.
|
|
322
|
+
</p>
|
|
323
|
+
<p className="text-sm text-blue-700">
|
|
324
|
+
Would you like to request to join the existing account, or create a new one?
|
|
325
|
+
</p>
|
|
326
|
+
<div className="flex gap-2">
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
onClick={() => setDomainMatchChoice('join')}
|
|
330
|
+
className="flex-1 py-2 px-3 rounded-[5px] bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
|
|
331
|
+
>
|
|
332
|
+
Request to join
|
|
333
|
+
</button>
|
|
334
|
+
<button
|
|
335
|
+
type="button"
|
|
336
|
+
onClick={() => setDomainMatchChoice('create_new')}
|
|
337
|
+
className="flex-1 py-2 px-3 rounded-[5px] border border-blue-300 text-blue-700 text-sm font-medium hover:bg-blue-100"
|
|
338
|
+
>
|
|
339
|
+
Create new account
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
|
|
345
|
+
{/* Company name — shown for company type when not joining existing */}
|
|
346
|
+
{!isInvite && useType === 'company' && domainMatchChoice !== 'join' && (
|
|
347
|
+
<div>
|
|
348
|
+
<label htmlFor="companyName" className="block text-sm font-medium text-slate-700">
|
|
349
|
+
Company name
|
|
350
|
+
</label>
|
|
351
|
+
<input
|
|
352
|
+
id="companyName"
|
|
353
|
+
type="text"
|
|
354
|
+
required={useType === 'company'}
|
|
355
|
+
value={companyName}
|
|
356
|
+
onChange={e => setCompanyName(e.target.value)}
|
|
357
|
+
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"
|
|
358
|
+
placeholder="Enter your company name"
|
|
359
|
+
/>
|
|
360
|
+
</div>
|
|
361
|
+
)}
|
|
362
|
+
|
|
363
|
+
{/* Invite account name — locked */}
|
|
364
|
+
{isInvite && invitePreview.account_name && (
|
|
365
|
+
<div>
|
|
366
|
+
<label className="block text-sm font-medium text-slate-700">Account</label>
|
|
367
|
+
<input
|
|
368
|
+
type="text"
|
|
369
|
+
readOnly
|
|
370
|
+
value={invitePreview.account_name}
|
|
371
|
+
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"
|
|
372
|
+
/>
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
{/* Password */}
|
|
124
377
|
<div>
|
|
125
378
|
<label htmlFor="password" className="block text-sm font-medium text-slate-700">
|
|
126
379
|
Password
|
|
127
380
|
</label>
|
|
128
381
|
<input
|
|
129
382
|
id="password"
|
|
130
|
-
name="password"
|
|
131
383
|
type="password"
|
|
132
|
-
autoComplete="new-password"
|
|
133
384
|
required
|
|
134
385
|
minLength={6}
|
|
135
386
|
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-
|
|
387
|
+
onChange={e => setPassword(e.target.value)}
|
|
388
|
+
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
389
|
placeholder="Choose a password (min 6 characters)"
|
|
139
390
|
/>
|
|
140
391
|
</div>
|
|
141
392
|
</div>
|
|
142
393
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
394
|
+
{/* Join request info banner */}
|
|
395
|
+
{domainMatchChoice === 'join' && (
|
|
396
|
+
<div className="rounded-md bg-amber-50 border border-amber-200 p-3">
|
|
397
|
+
<p className="text-sm text-amber-800">
|
|
398
|
+
Your registration will be submitted for approval. You'll gain access once an administrator approves your request.
|
|
399
|
+
</p>
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
<button
|
|
404
|
+
type="submit"
|
|
405
|
+
disabled={isLoading || (showDomainMatchPrompt ? false : false)}
|
|
406
|
+
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
|
+
>
|
|
408
|
+
{isLoading ? (
|
|
409
|
+
<>
|
|
410
|
+
<LoadingSpinner size="sm" />
|
|
411
|
+
<span className="ml-2">Creating account...</span>
|
|
412
|
+
</>
|
|
413
|
+
) : domainMatchChoice === 'join' ? (
|
|
414
|
+
'Request access'
|
|
415
|
+
) : (
|
|
416
|
+
'Create account'
|
|
417
|
+
)}
|
|
418
|
+
</button>
|
|
159
419
|
</form>
|
|
160
420
|
</div>
|
|
161
421
|
</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
|
|