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.
@@ -4,42 +4,132 @@
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 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
- const [fullName, setFullName] = useState('')
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
- const navigate = useNavigate()
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
- setSuccess('Account created! Check your email for a confirmation link.')
51
- setTimeout(() => navigate('/login'), 3000)
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-accent-blue hover:text-accent-blue-light">
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
- <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>
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={(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"
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={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"
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={(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"
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
- <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>
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.54",
3
+ "version": "0.3.55",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {