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
|
@@ -77,6 +77,7 @@ export const context = createHandler(async (ctx, _body) => {
|
|
|
77
77
|
avatar_url,
|
|
78
78
|
account_id,
|
|
79
79
|
role_id,
|
|
80
|
+
status,
|
|
80
81
|
account:accounts!people_account_id_fkey(
|
|
81
82
|
id,
|
|
82
83
|
slug,
|
|
@@ -154,6 +155,7 @@ export const context = createHandler(async (ctx, _body) => {
|
|
|
154
155
|
roles: [roleSlug].filter(Boolean),
|
|
155
156
|
permissions: effectivePermissions,
|
|
156
157
|
is_system_admin: roleSlug === 'system_admin',
|
|
158
|
+
status: personData.status ?? 'active',
|
|
157
159
|
accessible_accounts: accessibleAccounts
|
|
158
160
|
}
|
|
159
161
|
})
|
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module invites
|
|
3
|
+
* @audience core-contributor
|
|
4
|
+
* @layer api-handler
|
|
5
|
+
* @stability stable
|
|
6
|
+
*
|
|
7
|
+
* Invite and access-request management. All invite/access-request state is
|
|
8
|
+
* stored directly on `people` rows — no separate table. Invite metadata lives
|
|
9
|
+
* in `people.data` under `invite_*` keys.
|
|
10
|
+
*
|
|
11
|
+
* **Routed by:** `GET/POST /.netlify/functions/invites?action=<action>`
|
|
12
|
+
*
|
|
13
|
+
* **Public actions (no auth required):**
|
|
14
|
+
* | action | method | description |
|
|
15
|
+
* |-------------------|--------|-------------|
|
|
16
|
+
* | check-domain | GET | Boolean: does this email domain have existing accounts? |
|
|
17
|
+
* | validate-token | GET | Validate invite token, return preview |
|
|
18
|
+
* | complete-registration | POST | Finalise walk-up registration after Supabase auth |
|
|
19
|
+
*
|
|
20
|
+
* **Protected actions (require *_admin or system_admin):**
|
|
21
|
+
* | action | method | description |
|
|
22
|
+
* |----------------|--------|-------------|
|
|
23
|
+
* | create-invite | POST | Create invite stub + return shareable URL |
|
|
24
|
+
* | list-invites | GET | List invites scoped to account (or all for system_admin) |
|
|
25
|
+
* | revoke-invite | POST | Mark invite inactive |
|
|
26
|
+
* | reinvite | POST | Issue new token on existing stub |
|
|
27
|
+
* | approve | POST | Approve access_request → activate person |
|
|
28
|
+
* | deny | POST | Deny access_request → deactivate person |
|
|
29
|
+
*
|
|
30
|
+
* **Invite metadata in people.data:**
|
|
31
|
+
* ```json
|
|
32
|
+
* {
|
|
33
|
+
* "invite_token": "abc123...",
|
|
34
|
+
* "invite_kind": "invite" | "access_request",
|
|
35
|
+
* "invite_expires_at": "2026-06-24T...",
|
|
36
|
+
* "invite_attempts": 0,
|
|
37
|
+
* "invite_blocked_at": null,
|
|
38
|
+
* "invite_invited_by": "person-uuid"
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* **people.status values used here:**
|
|
43
|
+
* invited — unclaimed admin invite
|
|
44
|
+
* pending — walk-up access request awaiting approval
|
|
45
|
+
* active — approved / invite redeemed
|
|
46
|
+
* inactive — denied / revoked
|
|
47
|
+
*
|
|
48
|
+
* @seeAlso migrations/016_invites.sql (indexes)
|
|
49
|
+
* @seeAlso migrations/005_auth_user_trigger.sql (stub resolution on signup)
|
|
50
|
+
* @seeAlso src/pages/auth/RegisterPage.tsx (frontend)
|
|
51
|
+
* @seeAlso src/components/shared/InviteManager.tsx (UI)
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { createHandler } from './_shared/middleware'
|
|
55
|
+
import { adminDb } from './_shared/db'
|
|
56
|
+
import { emitLog } from './_shared/audit'
|
|
57
|
+
|
|
58
|
+
// ─── CONSTANTS ────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const TOKEN_LENGTH = 16
|
|
61
|
+
const MAX_ATTEMPTS = 5
|
|
62
|
+
const INVITE_EXPIRY_DAYS = 7
|
|
63
|
+
|
|
64
|
+
// Personal/free email domains — excluded from domain-match logic
|
|
65
|
+
const PERSONAL_DOMAINS = new Set([
|
|
66
|
+
'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com',
|
|
67
|
+
'aol.com', 'protonmail.com', 'me.com', 'mac.com', 'live.com',
|
|
68
|
+
'msn.com', 'ymail.com', 'googlemail.com', 'fastmail.com',
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function generateToken(): string {
|
|
74
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
75
|
+
const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_LENGTH))
|
|
76
|
+
return Array.from(bytes).map(b => chars[b % chars.length]).join('')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function generateAccountSlug(name: string): string {
|
|
80
|
+
const base = name
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
83
|
+
.replace(/^-+|-+$/g, '')
|
|
84
|
+
.slice(0, 40)
|
|
85
|
+
const hash = Math.random().toString(36).slice(2, 8)
|
|
86
|
+
return `${base}-${hash}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isAdminRole(roleSlug: string | undefined): boolean {
|
|
90
|
+
return !!roleSlug && (
|
|
91
|
+
roleSlug === 'system_admin' ||
|
|
92
|
+
roleSlug.endsWith('_admin')
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── PUBLIC: check-domain ─────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Returns { match: boolean } — whether any active people rows exist with the
|
|
100
|
+
* same email domain AND it's not a known personal domain.
|
|
101
|
+
* Does NOT return account names or IDs to avoid leaking structure.
|
|
102
|
+
*/
|
|
103
|
+
export const checkDomain = createHandler(async (_ctx, _body) => {
|
|
104
|
+
const email = _ctx.query?.email as string | undefined
|
|
105
|
+
if (!email || !email.includes('@')) {
|
|
106
|
+
throw new Error('Valid email is required')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const domain = email.split('@')[1].toLowerCase()
|
|
110
|
+
|
|
111
|
+
if (PERSONAL_DOMAINS.has(domain)) {
|
|
112
|
+
return { match: false }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { data } = await adminDb
|
|
116
|
+
.from('people')
|
|
117
|
+
.select('id')
|
|
118
|
+
.ilike('email', `%@${domain}`)
|
|
119
|
+
.eq('status', 'active')
|
|
120
|
+
.limit(1)
|
|
121
|
+
|
|
122
|
+
return { match: (data?.length ?? 0) > 0 }
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ─── PUBLIC: validate-token ───────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Looks up a people row by invite_token. Returns a safe preview (no UUIDs).
|
|
129
|
+
* Increments invite_attempts; blocks the token after MAX_ATTEMPTS failures.
|
|
130
|
+
*/
|
|
131
|
+
export const validateToken = createHandler(async (_ctx, _body) => {
|
|
132
|
+
const token = _ctx.query?.token as string | undefined
|
|
133
|
+
if (!token) throw new Error('Token is required')
|
|
134
|
+
|
|
135
|
+
const { data: person } = await adminDb
|
|
136
|
+
.from('people')
|
|
137
|
+
.select('id, email, account_id, role_id, status, data, account:accounts(display_name), role:roles(slug, name)')
|
|
138
|
+
.filter('data->>invite_token', 'eq', token)
|
|
139
|
+
.in('status', ['invited', 'pending'])
|
|
140
|
+
.single()
|
|
141
|
+
|
|
142
|
+
if (!person) {
|
|
143
|
+
throw new Error('Invite not found or already used')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const meta = person.data || {}
|
|
147
|
+
|
|
148
|
+
// Check blocked
|
|
149
|
+
if (meta.invite_blocked_at) {
|
|
150
|
+
throw new Error('This invite link has been blocked. Please contact the administrator.')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check expiry
|
|
154
|
+
if (meta.invite_expires_at && new Date(meta.invite_expires_at) < new Date()) {
|
|
155
|
+
throw new Error('This invite link has expired. Please request a new one.')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Increment attempts
|
|
159
|
+
const attempts = (meta.invite_attempts || 0) + 1
|
|
160
|
+
const blocked = attempts >= MAX_ATTEMPTS
|
|
161
|
+
|
|
162
|
+
await adminDb
|
|
163
|
+
.from('people')
|
|
164
|
+
.update({
|
|
165
|
+
data: {
|
|
166
|
+
...meta,
|
|
167
|
+
invite_attempts: attempts,
|
|
168
|
+
invite_blocked_at: blocked ? new Date().toISOString() : null,
|
|
169
|
+
},
|
|
170
|
+
updated_at: new Date().toISOString(),
|
|
171
|
+
})
|
|
172
|
+
.eq('id', person.id)
|
|
173
|
+
|
|
174
|
+
if (blocked) {
|
|
175
|
+
throw new Error('Too many failed attempts. This invite has been blocked.')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const account = Array.isArray(person.account) ? person.account[0] : person.account
|
|
179
|
+
const role = Array.isArray(person.role) ? person.role[0] : person.role
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
email: person.email,
|
|
183
|
+
account_name: account?.display_name ?? null,
|
|
184
|
+
role_name: role?.name ?? null,
|
|
185
|
+
kind: meta.invite_kind,
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ─── PUBLIC: complete-registration ───────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Called by RegisterPage after Supabase auth.signUp completes.
|
|
193
|
+
* Handles three walk-up paths:
|
|
194
|
+
* new_account — creates account + activates person as *_admin
|
|
195
|
+
* join_request — creates access_request stub (person stays pending)
|
|
196
|
+
* (invite path is handled entirely in the DB trigger)
|
|
197
|
+
*/
|
|
198
|
+
export const completeRegistration = createHandler(async (ctx, body) => {
|
|
199
|
+
const { path, account_name, email } = body
|
|
200
|
+
|
|
201
|
+
if (!path || !email) {
|
|
202
|
+
throw new Error('path and email are required')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (path === 'new_account') {
|
|
206
|
+
if (!account_name) throw new Error('account_name is required for new_account path')
|
|
207
|
+
|
|
208
|
+
// Resolve person type_id
|
|
209
|
+
const { data: personType } = await adminDb
|
|
210
|
+
.from('types')
|
|
211
|
+
.select('id, design_schema, validation_schema')
|
|
212
|
+
.eq('kind', 'person')
|
|
213
|
+
.eq('slug', 'person')
|
|
214
|
+
.single()
|
|
215
|
+
|
|
216
|
+
// Resolve account type_id
|
|
217
|
+
const { data: accountType } = await adminDb
|
|
218
|
+
.from('types')
|
|
219
|
+
.select('id')
|
|
220
|
+
.eq('kind', 'account')
|
|
221
|
+
.eq('slug', 'account')
|
|
222
|
+
.single()
|
|
223
|
+
|
|
224
|
+
// Resolve member_admin role (fall back to member if not seeded yet)
|
|
225
|
+
const { data: role } = await adminDb
|
|
226
|
+
.from('roles')
|
|
227
|
+
.select('id')
|
|
228
|
+
.in('slug', ['member_admin', 'member'])
|
|
229
|
+
.order('slug') // member_admin sorts before member
|
|
230
|
+
.limit(1)
|
|
231
|
+
.single()
|
|
232
|
+
|
|
233
|
+
// Create account
|
|
234
|
+
const slug = generateAccountSlug(account_name)
|
|
235
|
+
const { data: account, error: accErr } = await adminDb
|
|
236
|
+
.from('accounts')
|
|
237
|
+
.insert({
|
|
238
|
+
slug,
|
|
239
|
+
display_name: account_name,
|
|
240
|
+
type_id: accountType?.id,
|
|
241
|
+
is_active: true,
|
|
242
|
+
})
|
|
243
|
+
.select('id')
|
|
244
|
+
.single()
|
|
245
|
+
|
|
246
|
+
if (accErr || !account) throw new Error('Failed to create account: ' + accErr?.message)
|
|
247
|
+
|
|
248
|
+
// Update the walk-up person shell (created by trigger as minimal record)
|
|
249
|
+
const { error: pErr } = await adminDb
|
|
250
|
+
.from('people')
|
|
251
|
+
.update({
|
|
252
|
+
account_id: account.id,
|
|
253
|
+
role_id: role?.id ?? null,
|
|
254
|
+
status: 'active',
|
|
255
|
+
updated_at: new Date().toISOString(),
|
|
256
|
+
})
|
|
257
|
+
.eq('email', email)
|
|
258
|
+
.eq('status', 'active')
|
|
259
|
+
.is('role_id', null) // only the newly created shell
|
|
260
|
+
|
|
261
|
+
if (pErr) throw new Error('Failed to update person: ' + pErr.message)
|
|
262
|
+
|
|
263
|
+
return { status: 'active', account_slug: slug }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (path === 'join_request') {
|
|
267
|
+
const { target_account_id } = body
|
|
268
|
+
if (!target_account_id) throw new Error('target_account_id is required for join_request')
|
|
269
|
+
|
|
270
|
+
// Resolve member role
|
|
271
|
+
const { data: role } = await adminDb
|
|
272
|
+
.from('roles')
|
|
273
|
+
.select('id')
|
|
274
|
+
.eq('slug', 'member')
|
|
275
|
+
.single()
|
|
276
|
+
|
|
277
|
+
// Resolve type_id
|
|
278
|
+
const { data: personType } = await adminDb
|
|
279
|
+
.from('types')
|
|
280
|
+
.select('id, design_schema, validation_schema')
|
|
281
|
+
.eq('kind', 'person')
|
|
282
|
+
.eq('slug', 'person')
|
|
283
|
+
.single()
|
|
284
|
+
|
|
285
|
+
// Update the walk-up shell to be a pending access request on the target account
|
|
286
|
+
const { error: pErr } = await adminDb
|
|
287
|
+
.from('people')
|
|
288
|
+
.update({
|
|
289
|
+
account_id: target_account_id,
|
|
290
|
+
role_id: role?.id ?? null,
|
|
291
|
+
status: 'pending',
|
|
292
|
+
data: {
|
|
293
|
+
invite_kind: 'access_request',
|
|
294
|
+
invite_invited_by: null,
|
|
295
|
+
invite_token: null,
|
|
296
|
+
},
|
|
297
|
+
updated_at: new Date().toISOString(),
|
|
298
|
+
})
|
|
299
|
+
.eq('email', email)
|
|
300
|
+
.eq('status', 'active')
|
|
301
|
+
.is('role_id', null)
|
|
302
|
+
|
|
303
|
+
if (pErr) throw new Error('Failed to create access request: ' + pErr.message)
|
|
304
|
+
|
|
305
|
+
return { status: 'pending' }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
throw new Error('Unknown registration path: ' + path)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// ─── PROTECTED: create-invite ─────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Creates a stub people row (status: 'invited') with invite metadata.
|
|
315
|
+
* Returns the shareable token URL.
|
|
316
|
+
* Requires *_admin role scoped to the target account, or system_admin.
|
|
317
|
+
*/
|
|
318
|
+
export const createInvite = createHandler(async (ctx, body) => {
|
|
319
|
+
if (!ctx.principal || ctx.principal.id === 'anonymous') {
|
|
320
|
+
throw new Error('Authentication required')
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const { email, account_id, role_id, base_url } = body
|
|
324
|
+
if (!email || !account_id || !role_id) {
|
|
325
|
+
throw new Error('email, account_id, and role_id are required')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Verify caller is admin for this account
|
|
329
|
+
const { data: caller } = await adminDb
|
|
330
|
+
.from('people')
|
|
331
|
+
.select('role:roles(slug)')
|
|
332
|
+
.eq('id', ctx.principal.id)
|
|
333
|
+
.eq('account_id', account_id)
|
|
334
|
+
.single()
|
|
335
|
+
|
|
336
|
+
const callerRole = Array.isArray(caller?.role) ? caller?.role[0] : caller?.role
|
|
337
|
+
const callerRoleSlug = callerRole?.slug
|
|
338
|
+
|
|
339
|
+
const isSystemAdmin = ctx.principal.permissions?.includes('system') ||
|
|
340
|
+
callerRoleSlug === 'system_admin'
|
|
341
|
+
const isAccountAdmin = isAdminRole(callerRoleSlug)
|
|
342
|
+
|
|
343
|
+
if (!isSystemAdmin && !isAccountAdmin) {
|
|
344
|
+
throw new Error('Insufficient permissions — *_admin role required to create invites')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Check for existing unclaimed invite for this email+account
|
|
348
|
+
const { data: existing } = await adminDb
|
|
349
|
+
.from('people')
|
|
350
|
+
.select('id')
|
|
351
|
+
.eq('email', email)
|
|
352
|
+
.eq('account_id', account_id)
|
|
353
|
+
.in('status', ['invited', 'active'])
|
|
354
|
+
.single()
|
|
355
|
+
|
|
356
|
+
if (existing) {
|
|
357
|
+
throw new Error('A person with this email already exists or has a pending invite for this account')
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Resolve type_id for person
|
|
361
|
+
const { data: personType } = await adminDb
|
|
362
|
+
.from('types')
|
|
363
|
+
.select('id, design_schema, validation_schema')
|
|
364
|
+
.eq('kind', 'person')
|
|
365
|
+
.eq('slug', 'person')
|
|
366
|
+
.single()
|
|
367
|
+
|
|
368
|
+
const token = generateToken()
|
|
369
|
+
const expiresAt = new Date(Date.now() + INVITE_EXPIRY_DAYS * 86400 * 1000).toISOString()
|
|
370
|
+
|
|
371
|
+
const { data: stub, error: insertErr } = await adminDb
|
|
372
|
+
.from('people')
|
|
373
|
+
.insert({
|
|
374
|
+
email,
|
|
375
|
+
full_name: email.split('@')[0],
|
|
376
|
+
account_id,
|
|
377
|
+
role_id,
|
|
378
|
+
type_id: personType?.id,
|
|
379
|
+
status: 'invited',
|
|
380
|
+
is_active: true,
|
|
381
|
+
design_schema: personType?.design_schema ?? {},
|
|
382
|
+
validation_schema: personType?.validation_schema ?? {},
|
|
383
|
+
data: {
|
|
384
|
+
invite_token: token,
|
|
385
|
+
invite_kind: 'invite',
|
|
386
|
+
invite_expires_at: expiresAt,
|
|
387
|
+
invite_attempts: 0,
|
|
388
|
+
invite_blocked_at: null,
|
|
389
|
+
invite_invited_by: ctx.principal.id,
|
|
390
|
+
},
|
|
391
|
+
})
|
|
392
|
+
.select('id')
|
|
393
|
+
.single()
|
|
394
|
+
|
|
395
|
+
if (insertErr || !stub) throw new Error('Failed to create invite: ' + insertErr?.message)
|
|
396
|
+
|
|
397
|
+
const appBaseUrl = base_url || process.env.APP_BASE_URL || 'https://yourapp.com'
|
|
398
|
+
const inviteUrl = `${appBaseUrl}/register?t=${token}`
|
|
399
|
+
|
|
400
|
+
await emitLog(ctx, 'invite.created', { type: 'person', id: stub.id }, {
|
|
401
|
+
after: { email, account_id, role_id, expires_at: expiresAt }
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
return { id: stub.id, token, invite_url: inviteUrl, expires_at: expiresAt }
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
// ─── PROTECTED: list-invites ──────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Lists people rows with invite metadata.
|
|
411
|
+
* system_admin sees all accounts; others see only their account.
|
|
412
|
+
*/
|
|
413
|
+
export const listInvites = createHandler(async (ctx, _body) => {
|
|
414
|
+
if (!ctx.principal || ctx.principal.id === 'anonymous') {
|
|
415
|
+
throw new Error('Authentication required')
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const isSystemAdmin = ctx.principal.permissions?.includes('system')
|
|
419
|
+
const { account_id: queryAccountId } = ctx.query || {}
|
|
420
|
+
|
|
421
|
+
let query = adminDb
|
|
422
|
+
.from('people')
|
|
423
|
+
.select(`
|
|
424
|
+
id, email, full_name, status, created_at, updated_at,
|
|
425
|
+
account_id, role_id,
|
|
426
|
+
account:accounts(id, slug, display_name),
|
|
427
|
+
role:roles(id, slug, name),
|
|
428
|
+
data
|
|
429
|
+
`)
|
|
430
|
+
.not('data->>invite_kind', 'is', null)
|
|
431
|
+
.order('created_at', { ascending: false })
|
|
432
|
+
|
|
433
|
+
if (!isSystemAdmin) {
|
|
434
|
+
// Scoped to caller's account
|
|
435
|
+
const scopedAccountId = queryAccountId || ctx.accountId
|
|
436
|
+
if (!scopedAccountId) throw new Error('Account scope required')
|
|
437
|
+
query = query.eq('account_id', scopedAccountId)
|
|
438
|
+
} else if (queryAccountId) {
|
|
439
|
+
query = query.eq('account_id', queryAccountId)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const { data, error } = await query
|
|
443
|
+
if (error) throw error
|
|
444
|
+
|
|
445
|
+
return (data || []).map(row => ({
|
|
446
|
+
id: row.id,
|
|
447
|
+
email: row.email,
|
|
448
|
+
full_name: row.full_name,
|
|
449
|
+
status: row.status,
|
|
450
|
+
kind: (row.data as any)?.invite_kind,
|
|
451
|
+
expires_at: (row.data as any)?.invite_expires_at,
|
|
452
|
+
blocked_at: (row.data as any)?.invite_blocked_at,
|
|
453
|
+
created_at: row.created_at,
|
|
454
|
+
account: Array.isArray(row.account) ? row.account[0] : row.account,
|
|
455
|
+
role: Array.isArray(row.role) ? row.role[0] : row.role,
|
|
456
|
+
}))
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
// ─── PROTECTED: revoke-invite ─────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
export const revokeInvite = createHandler(async (ctx, body) => {
|
|
462
|
+
if (!ctx.principal || ctx.principal.id === 'anonymous') throw new Error('Authentication required')
|
|
463
|
+
|
|
464
|
+
const { person_id } = body
|
|
465
|
+
if (!person_id) throw new Error('person_id is required')
|
|
466
|
+
|
|
467
|
+
const { data: person } = await adminDb
|
|
468
|
+
.from('people')
|
|
469
|
+
.select('id, account_id, status, data')
|
|
470
|
+
.eq('id', person_id)
|
|
471
|
+
.in('status', ['invited', 'pending'])
|
|
472
|
+
.single()
|
|
473
|
+
|
|
474
|
+
if (!person) throw new Error('Invite not found or already processed')
|
|
475
|
+
|
|
476
|
+
const { error } = await adminDb
|
|
477
|
+
.from('people')
|
|
478
|
+
.update({
|
|
479
|
+
status: 'inactive',
|
|
480
|
+
is_active: false,
|
|
481
|
+
data: { ...(person.data as any), invite_token: null },
|
|
482
|
+
updated_at: new Date().toISOString(),
|
|
483
|
+
})
|
|
484
|
+
.eq('id', person_id)
|
|
485
|
+
|
|
486
|
+
if (error) throw error
|
|
487
|
+
|
|
488
|
+
await emitLog(ctx, 'invite.revoked', { type: 'person', id: person_id }, {})
|
|
489
|
+
|
|
490
|
+
return { success: true }
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
// ─── PROTECTED: reinvite ──────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
export const reinvite = createHandler(async (ctx, body) => {
|
|
496
|
+
if (!ctx.principal || ctx.principal.id === 'anonymous') throw new Error('Authentication required')
|
|
497
|
+
|
|
498
|
+
const { person_id, base_url } = body
|
|
499
|
+
if (!person_id) throw new Error('person_id is required')
|
|
500
|
+
|
|
501
|
+
const { data: person } = await adminDb
|
|
502
|
+
.from('people')
|
|
503
|
+
.select('id, email, account_id, role_id, data, status')
|
|
504
|
+
.eq('id', person_id)
|
|
505
|
+
.single()
|
|
506
|
+
|
|
507
|
+
if (!person) throw new Error('Person not found')
|
|
508
|
+
|
|
509
|
+
const newToken = generateToken()
|
|
510
|
+
const expiresAt = new Date(Date.now() + INVITE_EXPIRY_DAYS * 86400 * 1000).toISOString()
|
|
511
|
+
|
|
512
|
+
const { error } = await adminDb
|
|
513
|
+
.from('people')
|
|
514
|
+
.update({
|
|
515
|
+
status: 'invited',
|
|
516
|
+
is_active: true,
|
|
517
|
+
data: {
|
|
518
|
+
...(person.data as any),
|
|
519
|
+
invite_token: newToken,
|
|
520
|
+
invite_kind: 'invite',
|
|
521
|
+
invite_expires_at: expiresAt,
|
|
522
|
+
invite_attempts: 0,
|
|
523
|
+
invite_blocked_at: null,
|
|
524
|
+
invite_invited_by: ctx.principal.id,
|
|
525
|
+
},
|
|
526
|
+
updated_at: new Date().toISOString(),
|
|
527
|
+
})
|
|
528
|
+
.eq('id', person_id)
|
|
529
|
+
|
|
530
|
+
if (error) throw error
|
|
531
|
+
|
|
532
|
+
const appBaseUrl = base_url || process.env.APP_BASE_URL || 'https://yourapp.com'
|
|
533
|
+
const inviteUrl = `${appBaseUrl}/register?t=${newToken}`
|
|
534
|
+
|
|
535
|
+
await emitLog(ctx, 'invite.reinvited', { type: 'person', id: person_id }, {
|
|
536
|
+
after: { email: person.email, expires_at: expiresAt }
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
return { token: newToken, invite_url: inviteUrl, expires_at: expiresAt }
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
// ─── PROTECTED: approve ───────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
export const approve = createHandler(async (ctx, body) => {
|
|
545
|
+
if (!ctx.principal || ctx.principal.id === 'anonymous') throw new Error('Authentication required')
|
|
546
|
+
|
|
547
|
+
const { person_id } = body
|
|
548
|
+
if (!person_id) throw new Error('person_id is required')
|
|
549
|
+
|
|
550
|
+
const { data: person } = await adminDb
|
|
551
|
+
.from('people')
|
|
552
|
+
.select('id, email, account_id, data')
|
|
553
|
+
.eq('id', person_id)
|
|
554
|
+
.eq('status', 'pending')
|
|
555
|
+
.single()
|
|
556
|
+
|
|
557
|
+
if (!person) throw new Error('Access request not found or already processed')
|
|
558
|
+
|
|
559
|
+
// Strip invite metadata on approval
|
|
560
|
+
const { invite_token, invite_kind, invite_expires_at, invite_attempts, invite_blocked_at, invite_invited_by, ...remainingData } =
|
|
561
|
+
(person.data as any) || {}
|
|
562
|
+
|
|
563
|
+
const { error } = await adminDb
|
|
564
|
+
.from('people')
|
|
565
|
+
.update({
|
|
566
|
+
status: 'active',
|
|
567
|
+
is_active: true,
|
|
568
|
+
data: remainingData,
|
|
569
|
+
updated_at: new Date().toISOString(),
|
|
570
|
+
})
|
|
571
|
+
.eq('id', person_id)
|
|
572
|
+
|
|
573
|
+
if (error) throw error
|
|
574
|
+
|
|
575
|
+
await emitLog(ctx, 'invite.approved', { type: 'person', id: person_id }, {
|
|
576
|
+
after: { email: person.email, approved_by: ctx.principal.id }
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
return { success: true }
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
// ─── PROTECTED: deny ──────────────────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
export const deny = createHandler(async (ctx, body) => {
|
|
585
|
+
if (!ctx.principal || ctx.principal.id === 'anonymous') throw new Error('Authentication required')
|
|
586
|
+
|
|
587
|
+
const { person_id } = body
|
|
588
|
+
if (!person_id) throw new Error('person_id is required')
|
|
589
|
+
|
|
590
|
+
const { data: person } = await adminDb
|
|
591
|
+
.from('people')
|
|
592
|
+
.select('id, email, account_id, data')
|
|
593
|
+
.eq('id', person_id)
|
|
594
|
+
.eq('status', 'pending')
|
|
595
|
+
.single()
|
|
596
|
+
|
|
597
|
+
if (!person) throw new Error('Access request not found or already processed')
|
|
598
|
+
|
|
599
|
+
const { error } = await adminDb
|
|
600
|
+
.from('people')
|
|
601
|
+
.update({
|
|
602
|
+
status: 'inactive',
|
|
603
|
+
is_active: false,
|
|
604
|
+
updated_at: new Date().toISOString(),
|
|
605
|
+
})
|
|
606
|
+
.eq('id', person_id)
|
|
607
|
+
|
|
608
|
+
if (error) throw error
|
|
609
|
+
|
|
610
|
+
await emitLog(ctx, 'invite.denied', { type: 'person', id: person_id }, {
|
|
611
|
+
after: { email: person.email, denied_by: ctx.principal.id }
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
return { success: true }
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
// ─── MAIN HANDLER ────────────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
export const handler = createHandler(async (ctx, body) => {
|
|
620
|
+
const action = ctx.query?.action
|
|
621
|
+
|
|
622
|
+
switch (action) {
|
|
623
|
+
// Public
|
|
624
|
+
case 'check-domain': return await checkDomain(ctx, body)
|
|
625
|
+
case 'validate-token': return await validateToken(ctx, body)
|
|
626
|
+
case 'complete-registration': return await completeRegistration(ctx, body)
|
|
627
|
+
// Protected
|
|
628
|
+
case 'create-invite': return await createInvite(ctx, body)
|
|
629
|
+
case 'list-invites': return await listInvites(ctx, body)
|
|
630
|
+
case 'revoke-invite': return await revokeInvite(ctx, body)
|
|
631
|
+
case 'reinvite': return await reinvite(ctx, body)
|
|
632
|
+
case 'approve': return await approve(ctx, body)
|
|
633
|
+
case 'deny': return await deny(ctx, body)
|
|
634
|
+
default:
|
|
635
|
+
throw new Error(`Unknown action: ${action}`)
|
|
636
|
+
}
|
|
637
|
+
})
|