spine-framework 0.3.94 → 0.3.96
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/install-app.ts +92 -0
- package/.framework/functions/invites.ts +104 -7
- package/.framework/seeds/types/funnel_signal_use.json +5 -3
- package/.framework/seeds/types/spine_instance.json +5 -3
- package/.framework/src/components/shared/InviteManager.tsx +262 -81
- package/.framework/src/pages/admin/InvitesPage.tsx +15 -4
- package/.framework/src/pages/auth/RegisterPage.tsx +3 -0
- package/package.json +1 -1
|
@@ -22,6 +22,7 @@ import { existsSync, readFileSync, mkdirSync, cpSync, writeFileSync } from 'fs'
|
|
|
22
22
|
import { resolve, dirname, join, basename } from 'path'
|
|
23
23
|
import { fileURLToPath } from 'url'
|
|
24
24
|
import { execSync } from 'child_process'
|
|
25
|
+
import * as readline from 'readline'
|
|
25
26
|
import { createClient } from '@supabase/supabase-js'
|
|
26
27
|
import { runMigrations } from './migrate.js'
|
|
27
28
|
|
|
@@ -388,6 +389,9 @@ async function seedApp(appDir: string, appSlug: string) {
|
|
|
388
389
|
table: string
|
|
389
390
|
conflict: string
|
|
390
391
|
inject_app_id?: boolean
|
|
392
|
+
// When true: only patch design_schema.record_permissions on existing types.
|
|
393
|
+
// The record must have { kind, slug, record_permissions } — no app_id injection.
|
|
394
|
+
permissions_patch?: boolean
|
|
391
395
|
}
|
|
392
396
|
|
|
393
397
|
const seedEntries: SeedEntry[] = Array.isArray(manifest.seed)
|
|
@@ -399,6 +403,18 @@ async function seedApp(appDir: string, appSlug: string) {
|
|
|
399
403
|
{ file: 'seed/link-types.json', table: 'link_types', conflict: 'app_id,slug' },
|
|
400
404
|
]
|
|
401
405
|
|
|
406
|
+
// Always append the permissions patch file if it exists, unless already declared
|
|
407
|
+
const hasPermissionsPatch = seedEntries.some(e => e.permissions_patch)
|
|
408
|
+
if (!hasPermissionsPatch) {
|
|
409
|
+
seedEntries.push({
|
|
410
|
+
file: 'seed/type_permissions.json',
|
|
411
|
+
table: 'types',
|
|
412
|
+
conflict: 'kind,slug',
|
|
413
|
+
inject_app_id: false,
|
|
414
|
+
permissions_patch: true,
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
402
418
|
for (const entry of seedEntries) {
|
|
403
419
|
const filePath = join(appDir, entry.file)
|
|
404
420
|
if (!existsSync(filePath)) {
|
|
@@ -419,6 +435,70 @@ async function seedApp(appDir: string, appSlug: string) {
|
|
|
419
435
|
continue
|
|
420
436
|
}
|
|
421
437
|
|
|
438
|
+
// ── type_permissions.json: targeted record_permissions patch with check-and-choose ──
|
|
439
|
+
if (entry.permissions_patch) {
|
|
440
|
+
console.log(` Patching record_permissions for ${records.length} type(s) from ${entry.file}...`)
|
|
441
|
+
for (const record of records) {
|
|
442
|
+
const { kind, slug, record_permissions: newPerms } = record
|
|
443
|
+
if (!kind || !slug || !newPerms) {
|
|
444
|
+
console.warn(` ⚠ Skipping malformed entry (requires kind, slug, record_permissions)`)
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Fetch the existing type (match on kind+slug regardless of app_id)
|
|
449
|
+
const { data: existing } = await db
|
|
450
|
+
.from('types')
|
|
451
|
+
.select('id, design_schema')
|
|
452
|
+
.eq('kind', kind)
|
|
453
|
+
.eq('slug', slug)
|
|
454
|
+
.maybeSingle()
|
|
455
|
+
|
|
456
|
+
if (!existing) {
|
|
457
|
+
console.log(` ○ ${kind}/${slug} not found in DB — skipping (install the owning app first)`)
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const existingSchema = (existing.design_schema ?? {}) as Record<string, any>
|
|
462
|
+
const existingPerms = existingSchema.record_permissions ?? null
|
|
463
|
+
|
|
464
|
+
// Deep-equal check
|
|
465
|
+
if (JSON.stringify(existingPerms) === JSON.stringify(newPerms)) {
|
|
466
|
+
console.log(` ✓ ${kind}/${slug} record_permissions unchanged — skipped`)
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Show diff and prompt
|
|
471
|
+
console.log(`\n Type: ${kind}/${slug}`)
|
|
472
|
+
console.log(` Existing record_permissions:`)
|
|
473
|
+
console.log(' ' + JSON.stringify(existingPerms, null, 2).replace(/\n/g, '\n '))
|
|
474
|
+
console.log(` New record_permissions:`)
|
|
475
|
+
console.log(' ' + JSON.stringify(newPerms, null, 2).replace(/\n/g, '\n '))
|
|
476
|
+
|
|
477
|
+
const choice = await prompt(' Replace? [R]eplace / [K]eep → ')
|
|
478
|
+
if (choice.trim().toLowerCase() !== 'r') {
|
|
479
|
+
console.log(` ○ Kept existing`)
|
|
480
|
+
continue
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Targeted JSON merge: only update record_permissions key in design_schema
|
|
484
|
+
const { error: patchErr } = await db
|
|
485
|
+
.from('types')
|
|
486
|
+
.update({
|
|
487
|
+
design_schema: { ...existingSchema, record_permissions: newPerms },
|
|
488
|
+
})
|
|
489
|
+
.eq('id', existing.id)
|
|
490
|
+
|
|
491
|
+
if (patchErr) {
|
|
492
|
+
console.warn(` ⚠ ${kind}/${slug}: ${patchErr.message}`)
|
|
493
|
+
} else {
|
|
494
|
+
console.log(` ✓ ${kind}/${slug} record_permissions updated`)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
console.log(` ✓ type_permissions patch complete`)
|
|
498
|
+
continue
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ── Standard seed: upsert whole record ────────────────────────────────────
|
|
422
502
|
const injectAppId = entry.inject_app_id !== false // default true
|
|
423
503
|
console.log(` Seeding ${records.length} record(s) from ${entry.file} → ${entry.table}...`)
|
|
424
504
|
|
|
@@ -437,6 +517,18 @@ async function seedApp(appDir: string, appSlug: string) {
|
|
|
437
517
|
}
|
|
438
518
|
}
|
|
439
519
|
|
|
520
|
+
// ─── PROMPT HELPER ──────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
function prompt(question: string): Promise<string> {
|
|
523
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
524
|
+
return new Promise(resolve => {
|
|
525
|
+
rl.question(question, answer => {
|
|
526
|
+
rl.close()
|
|
527
|
+
resolve(answer)
|
|
528
|
+
})
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
|
|
440
532
|
// ─── SPINE CONFIG HELPERS ───────────────────────────────────────────────────
|
|
441
533
|
|
|
442
534
|
interface RegistrationConfig {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* | list-invites | GET | List invites scoped to account (or all for system_admin) |
|
|
26
26
|
* | revoke-invite | POST | Mark invite inactive |
|
|
27
27
|
* | reinvite | POST | Issue new token on existing stub |
|
|
28
|
+
* | block-invite | POST | Manually block an invite token |
|
|
28
29
|
* | approve | POST | Approve access_request → activate person |
|
|
29
30
|
* | deny | POST | Deny access_request → deactivate person |
|
|
30
31
|
*
|
|
@@ -197,6 +198,7 @@ export const validateToken = createHandler(async (_ctx, _body) => {
|
|
|
197
198
|
|
|
198
199
|
return {
|
|
199
200
|
email: person.email,
|
|
201
|
+
full_name: person.full_name ?? null,
|
|
200
202
|
account_name: account?.display_name ?? null,
|
|
201
203
|
role_name: role?.name ?? null,
|
|
202
204
|
kind: meta.invite_kind,
|
|
@@ -444,15 +446,22 @@ export const completeRegistration = createHandler(async (ctx, body) => {
|
|
|
444
446
|
* Creates a stub people row (status: 'invited') with invite metadata.
|
|
445
447
|
* Returns the shareable token URL.
|
|
446
448
|
* Requires *_admin role scoped to the target account, or system_admin.
|
|
449
|
+
*
|
|
450
|
+
* @param full_name - Invitee's name (admin-entered, shown on register page)
|
|
451
|
+
* @param email - Invitee's email address (locked on register page)
|
|
452
|
+
* @param account_id - Account to invite into
|
|
453
|
+
* @param role_slug - Role slug to assign (e.g. 'member', 'support')
|
|
454
|
+
* @param allowed_role_slugs - Optional whitelist of permitted role slugs for this context
|
|
455
|
+
* @param base_url - Origin to use for the invite URL (window.location.origin from client)
|
|
447
456
|
*/
|
|
448
457
|
export const createInvite = createHandler(async (ctx, body) => {
|
|
449
458
|
if (!ctx.principal || ctx.principal.id === 'anonymous') {
|
|
450
459
|
throw new Error('Authentication required')
|
|
451
460
|
}
|
|
452
461
|
|
|
453
|
-
const { email, account_id,
|
|
454
|
-
if (!email || !account_id || !
|
|
455
|
-
throw new Error('email, account_id, and
|
|
462
|
+
const { full_name, email, account_id, role_slug, allowed_role_slugs, base_url } = body
|
|
463
|
+
if (!email || !account_id || !role_slug) {
|
|
464
|
+
throw new Error('email, account_id, and role_slug are required')
|
|
456
465
|
}
|
|
457
466
|
|
|
458
467
|
// Verify caller is admin for this account
|
|
@@ -474,6 +483,21 @@ export const createInvite = createHandler(async (ctx, body) => {
|
|
|
474
483
|
throw new Error('Insufficient permissions — *_admin role required to create invites')
|
|
475
484
|
}
|
|
476
485
|
|
|
486
|
+
// Enforce allowed_role_slugs whitelist if provided
|
|
487
|
+
if (allowed_role_slugs && Array.isArray(allowed_role_slugs) && !allowed_role_slugs.includes(role_slug)) {
|
|
488
|
+
throw new Error(`Role '${role_slug}' is not permitted in this context`)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Resolve role_id from slug
|
|
492
|
+
const { data: roleRows } = await adminDb
|
|
493
|
+
.from('roles')
|
|
494
|
+
.select('id, slug')
|
|
495
|
+
.eq('slug', role_slug)
|
|
496
|
+
.limit(1)
|
|
497
|
+
|
|
498
|
+
const resolvedRole = roleRows?.[0]
|
|
499
|
+
if (!resolvedRole) throw new Error(`Role '${role_slug}' not found`)
|
|
500
|
+
|
|
477
501
|
// Check for existing unclaimed invite for this email+account
|
|
478
502
|
const { data: existing } = await adminDb
|
|
479
503
|
.from('people')
|
|
@@ -497,14 +521,15 @@ export const createInvite = createHandler(async (ctx, body) => {
|
|
|
497
521
|
|
|
498
522
|
const token = generateToken()
|
|
499
523
|
const expiresAt = new Date(Date.now() + INVITE_EXPIRY_DAYS * 86400 * 1000).toISOString()
|
|
524
|
+
const stubName = (full_name && full_name.trim()) ? full_name.trim() : email.split('@')[0]
|
|
500
525
|
|
|
501
526
|
const { data: stub, error: insertErr } = await adminDb
|
|
502
527
|
.from('people')
|
|
503
528
|
.insert({
|
|
504
529
|
email,
|
|
505
|
-
full_name:
|
|
530
|
+
full_name: stubName,
|
|
506
531
|
account_id,
|
|
507
|
-
role_id,
|
|
532
|
+
role_id: resolvedRole.id,
|
|
508
533
|
type_id: personType?.id,
|
|
509
534
|
status: 'invited',
|
|
510
535
|
is_active: true,
|
|
@@ -528,7 +553,7 @@ export const createInvite = createHandler(async (ctx, body) => {
|
|
|
528
553
|
const inviteUrl = `${appBaseUrl}/register?t=${token}`
|
|
529
554
|
|
|
530
555
|
await emitLog(ctx, 'invite.created', { type: 'person', id: stub.id }, {
|
|
531
|
-
after: { email, account_id,
|
|
556
|
+
after: { email, full_name: stubName, account_id, role_slug, expires_at: expiresAt }
|
|
532
557
|
})
|
|
533
558
|
|
|
534
559
|
return { id: stub.id, token, invite_url: inviteUrl, expires_at: expiresAt }
|
|
@@ -539,6 +564,10 @@ export const createInvite = createHandler(async (ctx, body) => {
|
|
|
539
564
|
/**
|
|
540
565
|
* Lists people rows with invite metadata.
|
|
541
566
|
* system_admin sees all accounts; others see only their account.
|
|
567
|
+
*
|
|
568
|
+
* @param account_id - Filter to a specific account (required for non-system_admin)
|
|
569
|
+
* @param role_slugs - Comma-separated role slugs to filter by (e.g. 'support,support_admin')
|
|
570
|
+
* When provided only people with one of these roles are returned.
|
|
542
571
|
*/
|
|
543
572
|
export const listInvites = createHandler(async (ctx, _body) => {
|
|
544
573
|
if (!ctx.principal || ctx.principal.id === 'anonymous') {
|
|
@@ -546,7 +575,27 @@ export const listInvites = createHandler(async (ctx, _body) => {
|
|
|
546
575
|
}
|
|
547
576
|
|
|
548
577
|
const isSystemAdmin = ctx.principal.permissions?.includes('system')
|
|
549
|
-
const { account_id: queryAccountId } = ctx.query || {}
|
|
578
|
+
const { account_id: queryAccountId, role_slugs: queryRoleSlugs } = ctx.query || {}
|
|
579
|
+
|
|
580
|
+
// Parse optional role slug filter (comma-separated string from query param)
|
|
581
|
+
const roleSlugsFilter: string[] | null = queryRoleSlugs
|
|
582
|
+
? (queryRoleSlugs as string).split(',').map((s: string) => s.trim()).filter(Boolean)
|
|
583
|
+
: null
|
|
584
|
+
|
|
585
|
+
// If role slug filter is provided, resolve to role IDs first
|
|
586
|
+
let roleIdFilter: string[] | null = null
|
|
587
|
+
if (roleSlugsFilter && roleSlugsFilter.length > 0) {
|
|
588
|
+
const { data: roleRows } = await adminDb
|
|
589
|
+
.from('roles')
|
|
590
|
+
.select('id, slug')
|
|
591
|
+
.in('slug', roleSlugsFilter)
|
|
592
|
+
|
|
593
|
+
roleIdFilter = (roleRows || []).map(r => r.id)
|
|
594
|
+
if (roleIdFilter.length === 0) {
|
|
595
|
+
// No matching roles — return empty list rather than unfiltered
|
|
596
|
+
return []
|
|
597
|
+
}
|
|
598
|
+
}
|
|
550
599
|
|
|
551
600
|
let query = adminDb
|
|
552
601
|
.from('people')
|
|
@@ -569,6 +618,10 @@ export const listInvites = createHandler(async (ctx, _body) => {
|
|
|
569
618
|
query = query.eq('account_id', queryAccountId)
|
|
570
619
|
}
|
|
571
620
|
|
|
621
|
+
if (roleIdFilter) {
|
|
622
|
+
query = query.in('role_id', roleIdFilter)
|
|
623
|
+
}
|
|
624
|
+
|
|
572
625
|
const { data, error } = await query
|
|
573
626
|
if (error) throw error
|
|
574
627
|
|
|
@@ -709,6 +762,49 @@ export const approve = createHandler(async (ctx, body) => {
|
|
|
709
762
|
return { success: true }
|
|
710
763
|
})
|
|
711
764
|
|
|
765
|
+
// ─── PROTECTED: block-invite ─────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Manually blocks an invite — sets invite_blocked_at on the people.data JSONB.
|
|
769
|
+
* The token remains but validate-token will refuse it.
|
|
770
|
+
* Useful for flagging suspicious or failed invite attempts before they auto-block.
|
|
771
|
+
*/
|
|
772
|
+
export const blockInvite = createHandler(async (ctx, body) => {
|
|
773
|
+
if (!ctx.principal || ctx.principal.id === 'anonymous') throw new Error('Authentication required')
|
|
774
|
+
|
|
775
|
+
const { person_id } = body
|
|
776
|
+
if (!person_id) throw new Error('person_id is required')
|
|
777
|
+
|
|
778
|
+
const { data: person } = await adminDb
|
|
779
|
+
.from('people')
|
|
780
|
+
.select('id, email, account_id, data')
|
|
781
|
+
.eq('id', person_id)
|
|
782
|
+
.single()
|
|
783
|
+
|
|
784
|
+
if (!person) throw new Error('Person not found')
|
|
785
|
+
|
|
786
|
+
const meta = (person.data as any) || {}
|
|
787
|
+
|
|
788
|
+
const { error } = await adminDb
|
|
789
|
+
.from('people')
|
|
790
|
+
.update({
|
|
791
|
+
data: {
|
|
792
|
+
...meta,
|
|
793
|
+
invite_blocked_at: new Date().toISOString(),
|
|
794
|
+
},
|
|
795
|
+
updated_at: new Date().toISOString(),
|
|
796
|
+
})
|
|
797
|
+
.eq('id', person_id)
|
|
798
|
+
|
|
799
|
+
if (error) throw error
|
|
800
|
+
|
|
801
|
+
await emitLog(ctx, 'invite.blocked', { type: 'person', id: person_id }, {
|
|
802
|
+
after: { email: person.email, blocked_by: ctx.principal.id }
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
return { success: true }
|
|
806
|
+
})
|
|
807
|
+
|
|
712
808
|
// ─── PROTECTED: deny ──────────────────────────────────────────────────────────
|
|
713
809
|
|
|
714
810
|
export const deny = createHandler(async (ctx, body) => {
|
|
@@ -760,6 +856,7 @@ export const handler = createPublicHandler(async (ctx, body) => {
|
|
|
760
856
|
case 'list-invites': return await listInvites(ctx, body)
|
|
761
857
|
case 'revoke-invite': return await revokeInvite(ctx, body)
|
|
762
858
|
case 'reinvite': return await reinvite(ctx, body)
|
|
859
|
+
case 'block-invite': return await blockInvite(ctx, body)
|
|
763
860
|
case 'approve': return await approve(ctx, body)
|
|
764
861
|
case 'deny': return await deny(ctx, body)
|
|
765
862
|
default:
|
|
@@ -87,9 +87,11 @@
|
|
|
87
87
|
}
|
|
88
88
|
},
|
|
89
89
|
"record_permissions": {
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"
|
|
90
|
+
"system_admin": ["create", "read", "update", "delete"],
|
|
91
|
+
"support_admin": ["create", "read", "update"],
|
|
92
|
+
"support": ["create", "read", "update"],
|
|
93
|
+
"member_admin": ["create", "read", "update"],
|
|
94
|
+
"member": ["create", "read", "update"]
|
|
93
95
|
}
|
|
94
96
|
},
|
|
95
97
|
"validation_schema": {},
|
|
@@ -98,9 +98,11 @@
|
|
|
98
98
|
}
|
|
99
99
|
},
|
|
100
100
|
"record_permissions": {
|
|
101
|
-
"
|
|
102
|
-
"
|
|
103
|
-
"
|
|
101
|
+
"system_admin": ["create", "read", "update", "delete"],
|
|
102
|
+
"support_admin": ["create", "read", "update"],
|
|
103
|
+
"support": ["create", "read", "update"],
|
|
104
|
+
"member_admin": ["create", "read", "update"],
|
|
105
|
+
"member": ["create", "read", "update"]
|
|
104
106
|
}
|
|
105
107
|
},
|
|
106
108
|
"validation_schema": {},
|
|
@@ -8,13 +8,16 @@
|
|
|
8
8
|
* Consumed by Cortex TeamPage, Portal TeamPage, and the Admin InvitesPage.
|
|
9
9
|
*
|
|
10
10
|
* Layout:
|
|
11
|
-
* [ Email input ] [ Generate Link button ]
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* [ Name input ] [ Email input ] [ Role picker ] [ Generate Link button ]
|
|
12
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
13
|
+
* Searchable/sortable invite list table with status + actions
|
|
14
14
|
*
|
|
15
15
|
* @prop accountId - Scope invites to this account UUID.
|
|
16
16
|
* Omit (system_admin only) to show all accounts.
|
|
17
|
-
* @prop
|
|
17
|
+
* @prop allowedRoles - Roles the admin may assign. First entry is the default.
|
|
18
|
+
* If only one entry, no picker is shown.
|
|
19
|
+
* @prop roleSlugsFilter - Comma-separated role slugs to pass to list-invites.
|
|
20
|
+
* Limits which people appear in the list.
|
|
18
21
|
* @prop scope - 'account' = scoped to one account | 'system' = all accounts
|
|
19
22
|
*
|
|
20
23
|
* @seeAlso functions/invites.ts (API)
|
|
@@ -23,16 +26,22 @@
|
|
|
23
26
|
* @seeAlso custom/apps/portal/pages/team/TeamPage.tsx
|
|
24
27
|
*/
|
|
25
28
|
|
|
26
|
-
import React, { useState, useEffect, useCallback } from 'react'
|
|
29
|
+
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
|
27
30
|
import { apiFetch } from '../../lib/api'
|
|
28
31
|
|
|
29
32
|
// ─── TYPES ────────────────────────────────────────────────────────────────────
|
|
30
33
|
|
|
31
34
|
export type InviteScope = 'account' | 'system'
|
|
32
35
|
|
|
36
|
+
export interface AllowedRole {
|
|
37
|
+
slug: string
|
|
38
|
+
name: string
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
export interface InviteManagerProps {
|
|
34
42
|
accountId?: string
|
|
35
|
-
|
|
43
|
+
allowedRoles: AllowedRole[]
|
|
44
|
+
roleSlugsFilter?: string // comma-separated, passed to list-invites
|
|
36
45
|
scope: InviteScope
|
|
37
46
|
}
|
|
38
47
|
|
|
@@ -80,13 +89,13 @@ function deriveDisplayStatus(row: InviteRow): string {
|
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
const STATUS_CLASSES: Record<string, string> = {
|
|
83
|
-
pending:
|
|
92
|
+
pending: 'bg-yellow-100 text-yellow-800',
|
|
84
93
|
'pending approval': 'bg-blue-100 text-blue-800',
|
|
85
|
-
claimed:
|
|
86
|
-
approved:
|
|
87
|
-
expired:
|
|
88
|
-
blocked:
|
|
89
|
-
revoked:
|
|
94
|
+
claimed: 'bg-blue-100 text-blue-800',
|
|
95
|
+
approved: 'bg-green-100 text-green-800',
|
|
96
|
+
expired: 'bg-slate-100 text-slate-600',
|
|
97
|
+
blocked: 'bg-red-100 text-red-800',
|
|
98
|
+
revoked: 'bg-slate-100 text-slate-500',
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
function timeUntil(iso: string | null): string {
|
|
@@ -101,22 +110,37 @@ function timeUntil(iso: string | null): string {
|
|
|
101
110
|
|
|
102
111
|
// ─── COMPONENT ────────────────────────────────────────────────────────────────
|
|
103
112
|
|
|
104
|
-
export function InviteManager({ accountId,
|
|
113
|
+
export function InviteManager({ accountId, allowedRoles, roleSlugsFilter, scope }: InviteManagerProps) {
|
|
105
114
|
const [invites, setInvites] = useState<InviteRow[]>([])
|
|
106
115
|
const [loading, setLoading] = useState(true)
|
|
107
116
|
const [error, setError] = useState('')
|
|
108
117
|
|
|
118
|
+
const [newName, setNewName] = useState('')
|
|
109
119
|
const [newEmail, setNewEmail] = useState('')
|
|
120
|
+
const [selectedRoleSlug, setSelectedRoleSlug] = useState(allowedRoles[0]?.slug ?? '')
|
|
110
121
|
const [generating, setGenerating] = useState(false)
|
|
111
122
|
const [generateError, setGenerateError] = useState('')
|
|
112
123
|
const [copiedId, setCopiedId] = useState<string | null>(null)
|
|
113
124
|
|
|
125
|
+
// Search / sort state
|
|
126
|
+
const [search, setSearch] = useState('')
|
|
127
|
+
const [sortKey, setSortKey] = useState<'full_name' | 'email' | 'status' | 'created_at'>('created_at')
|
|
128
|
+
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
|
129
|
+
|
|
130
|
+
// Keep selectedRoleSlug in sync if allowedRoles changes
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!allowedRoles.find(r => r.slug === selectedRoleSlug)) {
|
|
133
|
+
setSelectedRoleSlug(allowedRoles[0]?.slug ?? '')
|
|
134
|
+
}
|
|
135
|
+
}, [allowedRoles, selectedRoleSlug])
|
|
136
|
+
|
|
114
137
|
// ── Load invites ──────────────────────────────────────────────────────────
|
|
115
138
|
const loadInvites = useCallback(async () => {
|
|
116
139
|
setLoading(true)
|
|
117
140
|
setError('')
|
|
118
141
|
const params: Record<string, string> = {}
|
|
119
142
|
if (accountId) params.account_id = accountId
|
|
143
|
+
if (roleSlugsFilter) params.role_slugs = roleSlugsFilter
|
|
120
144
|
|
|
121
145
|
const result = await callInvitesApi('list-invites', params)
|
|
122
146
|
setLoading(false)
|
|
@@ -125,10 +149,42 @@ export function InviteManager({ accountId, defaultRoleSlug, scope }: InviteManag
|
|
|
125
149
|
} else {
|
|
126
150
|
setInvites(result.data || [])
|
|
127
151
|
}
|
|
128
|
-
}, [accountId])
|
|
152
|
+
}, [accountId, roleSlugsFilter])
|
|
129
153
|
|
|
130
154
|
useEffect(() => { loadInvites() }, [loadInvites])
|
|
131
155
|
|
|
156
|
+
// ── Filtered + sorted list ─────────────────────────────────────────────────
|
|
157
|
+
const filteredInvites = useMemo(() => {
|
|
158
|
+
const q = search.toLowerCase()
|
|
159
|
+
const filtered = q
|
|
160
|
+
? invites.filter(r =>
|
|
161
|
+
r.full_name?.toLowerCase().includes(q) ||
|
|
162
|
+
r.email.toLowerCase().includes(q) ||
|
|
163
|
+
deriveDisplayStatus(r).includes(q) ||
|
|
164
|
+
r.role?.name?.toLowerCase().includes(q)
|
|
165
|
+
)
|
|
166
|
+
: invites
|
|
167
|
+
|
|
168
|
+
return [...filtered].sort((a, b) => {
|
|
169
|
+
let av = '', bv = ''
|
|
170
|
+
if (sortKey === 'full_name') { av = a.full_name ?? ''; bv = b.full_name ?? '' }
|
|
171
|
+
if (sortKey === 'email') { av = a.email; bv = b.email }
|
|
172
|
+
if (sortKey === 'status') { av = deriveDisplayStatus(a); bv = deriveDisplayStatus(b) }
|
|
173
|
+
if (sortKey === 'created_at') { av = a.created_at; bv = b.created_at }
|
|
174
|
+
const cmp = av.localeCompare(bv)
|
|
175
|
+
return sortDir === 'asc' ? cmp : -cmp
|
|
176
|
+
})
|
|
177
|
+
}, [invites, search, sortKey, sortDir])
|
|
178
|
+
|
|
179
|
+
const handleSort = (key: typeof sortKey) => {
|
|
180
|
+
if (sortKey === key) {
|
|
181
|
+
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
|
182
|
+
} else {
|
|
183
|
+
setSortKey(key)
|
|
184
|
+
setSortDir('asc')
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
132
188
|
// ── Generate invite ────────────────────────────────────────────────────────
|
|
133
189
|
const handleGenerate = async (e: React.FormEvent) => {
|
|
134
190
|
e.preventDefault()
|
|
@@ -137,9 +193,11 @@ export function InviteManager({ accountId, defaultRoleSlug, scope }: InviteManag
|
|
|
137
193
|
setGenerateError('')
|
|
138
194
|
|
|
139
195
|
const result = await callInvitesApi('create-invite', {
|
|
196
|
+
full_name: newName.trim() || undefined,
|
|
140
197
|
email: newEmail.trim(),
|
|
141
198
|
account_id: accountId,
|
|
142
|
-
role_slug:
|
|
199
|
+
role_slug: selectedRoleSlug,
|
|
200
|
+
allowed_role_slugs: allowedRoles.map(r => r.slug),
|
|
143
201
|
base_url: window.location.origin,
|
|
144
202
|
}, 'POST')
|
|
145
203
|
|
|
@@ -150,36 +208,17 @@ export function InviteManager({ accountId, defaultRoleSlug, scope }: InviteManag
|
|
|
150
208
|
return
|
|
151
209
|
}
|
|
152
210
|
|
|
153
|
-
// Copy URL to clipboard
|
|
211
|
+
// Copy URL to clipboard automatically
|
|
154
212
|
if (result.data?.invite_url) {
|
|
155
213
|
navigator.clipboard.writeText(result.data.invite_url).catch(() => {})
|
|
156
214
|
}
|
|
157
215
|
|
|
216
|
+
setNewName('')
|
|
158
217
|
setNewEmail('')
|
|
159
218
|
await loadInvites()
|
|
160
219
|
}
|
|
161
220
|
|
|
162
|
-
// ──
|
|
163
|
-
const handleCopy = async (row: InviteRow) => {
|
|
164
|
-
// Re-generate the URL from what we know — token isn't stored in list response for security
|
|
165
|
-
// Instead, issue a reinvite to get a fresh URL (safer — old token stays valid, we just display it)
|
|
166
|
-
// For now, signal to the user to use the generate flow. A future enhancement could cache the URL.
|
|
167
|
-
const url = `${window.location.origin}/register?t=<token-not-displayable>`
|
|
168
|
-
await navigator.clipboard.writeText(url).catch(() => {})
|
|
169
|
-
setCopiedId(row.id)
|
|
170
|
-
setTimeout(() => setCopiedId(null), 2000)
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ── Actions ────────────────────────────────────────────────────────────────
|
|
174
|
-
const handleAction = async (action: string, personId: string) => {
|
|
175
|
-
const result = await callInvitesApi(action, { person_id: personId }, 'POST')
|
|
176
|
-
if (result.error) {
|
|
177
|
-
setError(result.error)
|
|
178
|
-
} else {
|
|
179
|
-
await loadInvites()
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
221
|
+
// ── Reinvite (issues new token, copies URL) ────────────────────────────────
|
|
183
222
|
const handleReinvite = async (personId: string) => {
|
|
184
223
|
const result = await callInvitesApi('reinvite', {
|
|
185
224
|
person_id: personId,
|
|
@@ -197,37 +236,91 @@ export function InviteManager({ accountId, defaultRoleSlug, scope }: InviteManag
|
|
|
197
236
|
}
|
|
198
237
|
}
|
|
199
238
|
|
|
239
|
+
// ── Generic action (revoke, approve, deny, block) ─────────────────────────
|
|
240
|
+
const handleAction = async (action: string, personId: string) => {
|
|
241
|
+
const result = await callInvitesApi(action, { person_id: personId }, 'POST')
|
|
242
|
+
if (result.error) {
|
|
243
|
+
setError(result.error)
|
|
244
|
+
} else {
|
|
245
|
+
await loadInvites()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
200
249
|
// ── Render ─────────────────────────────────────────────────────────────────
|
|
250
|
+
const canGenerate = !!accountId || scope === 'system'
|
|
251
|
+
const showRolePicker = allowedRoles.length > 1
|
|
252
|
+
|
|
201
253
|
return (
|
|
202
254
|
<div className="space-y-6">
|
|
203
255
|
|
|
204
256
|
{/* Generate invite form */}
|
|
205
|
-
<form onSubmit={handleGenerate} className="
|
|
206
|
-
<div className="flex-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
257
|
+
<form onSubmit={handleGenerate} className="space-y-3">
|
|
258
|
+
<div className="flex gap-3 items-end flex-wrap">
|
|
259
|
+
{/* Name */}
|
|
260
|
+
<div className="flex-1 min-w-[160px]">
|
|
261
|
+
<label htmlFor="invite-name" className="block text-sm font-medium text-slate-700 mb-1">
|
|
262
|
+
Name
|
|
263
|
+
</label>
|
|
264
|
+
<input
|
|
265
|
+
id="invite-name"
|
|
266
|
+
type="text"
|
|
267
|
+
value={newName}
|
|
268
|
+
onChange={e => setNewName(e.target.value)}
|
|
269
|
+
placeholder="Jane Smith"
|
|
270
|
+
disabled={!canGenerate}
|
|
271
|
+
className="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 disabled:opacity-50"
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{/* Email */}
|
|
276
|
+
<div className="flex-1 min-w-[200px]">
|
|
277
|
+
<label htmlFor="invite-email" className="block text-sm font-medium text-slate-700 mb-1">
|
|
278
|
+
Email
|
|
279
|
+
</label>
|
|
280
|
+
<input
|
|
281
|
+
id="invite-email"
|
|
282
|
+
type="email"
|
|
283
|
+
required
|
|
284
|
+
value={newEmail}
|
|
285
|
+
onChange={e => setNewEmail(e.target.value)}
|
|
286
|
+
placeholder="colleague@company.com"
|
|
287
|
+
disabled={!canGenerate}
|
|
288
|
+
className="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 disabled:opacity-50"
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{/* Role picker — only shown when more than one role is available */}
|
|
293
|
+
{showRolePicker && (
|
|
294
|
+
<div className="min-w-[140px]">
|
|
295
|
+
<label htmlFor="invite-role" className="block text-sm font-medium text-slate-700 mb-1">
|
|
296
|
+
Role
|
|
297
|
+
</label>
|
|
298
|
+
<select
|
|
299
|
+
id="invite-role"
|
|
300
|
+
value={selectedRoleSlug}
|
|
301
|
+
onChange={e => setSelectedRoleSlug(e.target.value)}
|
|
302
|
+
disabled={!canGenerate}
|
|
303
|
+
className="block w-full px-3 py-2 border border-slate-300 rounded-[5px] shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm disabled:opacity-50 bg-white"
|
|
304
|
+
>
|
|
305
|
+
{allowedRoles.map(r => (
|
|
306
|
+
<option key={r.slug} value={r.slug}>{r.name}</option>
|
|
307
|
+
))}
|
|
308
|
+
</select>
|
|
309
|
+
</div>
|
|
222
310
|
)}
|
|
311
|
+
|
|
312
|
+
<button
|
|
313
|
+
type="submit"
|
|
314
|
+
disabled={generating || !newEmail.trim() || !canGenerate}
|
|
315
|
+
className="px-4 py-2 rounded-[5px] bg-slate-900 text-white text-sm font-medium hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap self-end"
|
|
316
|
+
>
|
|
317
|
+
{generating ? 'Generating...' : 'Generate Link'}
|
|
318
|
+
</button>
|
|
223
319
|
</div>
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
>
|
|
229
|
-
{generating ? 'Generating...' : 'Generate Link'}
|
|
230
|
-
</button>
|
|
320
|
+
|
|
321
|
+
{!canGenerate && (
|
|
322
|
+
<p className="text-xs text-slate-500">Select an account to enable invite generation.</p>
|
|
323
|
+
)}
|
|
231
324
|
</form>
|
|
232
325
|
|
|
233
326
|
{generateError && (
|
|
@@ -238,6 +331,17 @@ export function InviteManager({ accountId, defaultRoleSlug, scope }: InviteManag
|
|
|
238
331
|
|
|
239
332
|
<hr className="border-slate-200" />
|
|
240
333
|
|
|
334
|
+
{/* Search bar */}
|
|
335
|
+
<div>
|
|
336
|
+
<input
|
|
337
|
+
type="text"
|
|
338
|
+
value={search}
|
|
339
|
+
onChange={e => setSearch(e.target.value)}
|
|
340
|
+
placeholder="Search by name, email, or status…"
|
|
341
|
+
className="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"
|
|
342
|
+
/>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
241
345
|
{/* Error state */}
|
|
242
346
|
{error && (
|
|
243
347
|
<div className="rounded-md bg-red-50 p-3">
|
|
@@ -250,31 +354,45 @@ export function InviteManager({ accountId, defaultRoleSlug, scope }: InviteManag
|
|
|
250
354
|
<div className="flex items-center justify-center py-12">
|
|
251
355
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-slate-900" />
|
|
252
356
|
</div>
|
|
253
|
-
) :
|
|
357
|
+
) : filteredInvites.length === 0 ? (
|
|
254
358
|
<div className="text-center py-12 text-slate-500">
|
|
255
|
-
<p className="text-sm">
|
|
359
|
+
<p className="text-sm">
|
|
360
|
+
{invites.length === 0
|
|
361
|
+
? 'No invites yet. Generate a link above to invite someone.'
|
|
362
|
+
: 'No results match your search.'}
|
|
363
|
+
</p>
|
|
256
364
|
</div>
|
|
257
365
|
) : (
|
|
258
366
|
<div className="overflow-x-auto">
|
|
259
367
|
<table className="min-w-full divide-y divide-slate-200">
|
|
260
368
|
<thead>
|
|
261
369
|
<tr>
|
|
262
|
-
<
|
|
370
|
+
<SortTh label="Name" col="full_name" current={sortKey} dir={sortDir} onSort={handleSort} />
|
|
371
|
+
<SortTh label="Email" col="email" current={sortKey} dir={sortDir} onSort={handleSort} />
|
|
263
372
|
{scope === 'system' && (
|
|
264
373
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Account</th>
|
|
265
374
|
)}
|
|
266
375
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Role</th>
|
|
267
|
-
<
|
|
376
|
+
<SortTh label="Status" col="status" current={sortKey} dir={sortDir} onSort={handleSort} />
|
|
268
377
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Expires</th>
|
|
269
378
|
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">Actions</th>
|
|
270
379
|
</tr>
|
|
271
380
|
</thead>
|
|
272
381
|
<tbody className="divide-y divide-slate-100">
|
|
273
|
-
{
|
|
382
|
+
{filteredInvites.map(row => {
|
|
274
383
|
const displayStatus = deriveDisplayStatus(row)
|
|
384
|
+
const isPending = row.status === 'invited' && !row.blocked_at && !(row.expires_at && new Date(row.expires_at) < new Date())
|
|
385
|
+
const isExpired = displayStatus === 'expired'
|
|
386
|
+
const isBlocked = displayStatus === 'blocked'
|
|
387
|
+
const isRevoked = displayStatus === 'revoked'
|
|
388
|
+
const isClaimed = displayStatus === 'claimed'
|
|
389
|
+
const isApproved = displayStatus === 'approved'
|
|
390
|
+
const isAccessReq = row.status === 'pending' && row.kind === 'access_request'
|
|
391
|
+
|
|
275
392
|
return (
|
|
276
393
|
<tr key={row.id} className="hover:bg-slate-50">
|
|
277
|
-
<td className="px-4 py-3 text-sm text-slate-900">{row.
|
|
394
|
+
<td className="px-4 py-3 text-sm text-slate-900">{row.full_name || '—'}</td>
|
|
395
|
+
<td className="px-4 py-3 text-sm text-slate-600">{row.email}</td>
|
|
278
396
|
|
|
279
397
|
{scope === 'system' && (
|
|
280
398
|
<td className="px-4 py-3 text-sm text-slate-600">
|
|
@@ -297,11 +415,17 @@ export function InviteManager({ accountId, defaultRoleSlug, scope }: InviteManag
|
|
|
297
415
|
</td>
|
|
298
416
|
|
|
299
417
|
<td className="px-4 py-3 text-right">
|
|
300
|
-
<div className="flex gap-2 justify-end">
|
|
418
|
+
<div className="flex gap-2 justify-end flex-wrap">
|
|
301
419
|
|
|
302
|
-
{/* Pending unclaimed invite */}
|
|
303
|
-
{
|
|
420
|
+
{/* Pending unclaimed invite — Reinvite + Revoke */}
|
|
421
|
+
{isPending && (
|
|
304
422
|
<>
|
|
423
|
+
<button
|
|
424
|
+
onClick={() => handleReinvite(row.id)}
|
|
425
|
+
className="text-xs text-blue-600 hover:text-blue-800 px-2 py-1 rounded border border-blue-200 hover:border-blue-400"
|
|
426
|
+
>
|
|
427
|
+
{copiedId === row.id ? 'Copied!' : 'Reinvite'}
|
|
428
|
+
</button>
|
|
305
429
|
<button
|
|
306
430
|
onClick={() => handleAction('revoke-invite', row.id)}
|
|
307
431
|
className="text-xs text-slate-500 hover:text-red-600 px-2 py-1 rounded border border-slate-200 hover:border-red-300"
|
|
@@ -311,33 +435,67 @@ export function InviteManager({ accountId, defaultRoleSlug, scope }: InviteManag
|
|
|
311
435
|
</>
|
|
312
436
|
)}
|
|
313
437
|
|
|
314
|
-
{/*
|
|
315
|
-
{
|
|
438
|
+
{/* Expired — Reinvite + Revoke */}
|
|
439
|
+
{isExpired && (
|
|
316
440
|
<>
|
|
317
441
|
<button
|
|
318
|
-
onClick={() =>
|
|
319
|
-
className="text-xs text-
|
|
442
|
+
onClick={() => handleReinvite(row.id)}
|
|
443
|
+
className="text-xs text-blue-600 hover:text-blue-800 px-2 py-1 rounded border border-blue-200 hover:border-blue-400"
|
|
320
444
|
>
|
|
321
|
-
|
|
445
|
+
{copiedId === row.id ? 'Copied!' : 'Reinvite'}
|
|
322
446
|
</button>
|
|
323
447
|
<button
|
|
324
|
-
onClick={() => handleAction('
|
|
325
|
-
className="text-xs text-
|
|
448
|
+
onClick={() => handleAction('revoke-invite', row.id)}
|
|
449
|
+
className="text-xs text-slate-500 hover:text-red-600 px-2 py-1 rounded border border-slate-200 hover:border-red-300"
|
|
326
450
|
>
|
|
327
|
-
|
|
451
|
+
Revoke
|
|
328
452
|
</button>
|
|
329
453
|
</>
|
|
330
454
|
)}
|
|
331
455
|
|
|
332
|
-
{/*
|
|
333
|
-
{
|
|
456
|
+
{/* Blocked — Reinvite only (unblock + new token) */}
|
|
457
|
+
{isBlocked && (
|
|
334
458
|
<button
|
|
335
459
|
onClick={() => handleReinvite(row.id)}
|
|
336
460
|
className="text-xs text-blue-600 hover:text-blue-800 px-2 py-1 rounded border border-blue-200 hover:border-blue-400"
|
|
337
461
|
>
|
|
338
|
-
{copiedId === row.id ? 'Copied!' : '
|
|
462
|
+
{copiedId === row.id ? 'Copied!' : 'Reinvite'}
|
|
339
463
|
</button>
|
|
340
464
|
)}
|
|
465
|
+
|
|
466
|
+
{/* Revoked — Reinvite only */}
|
|
467
|
+
{isRevoked && (
|
|
468
|
+
<button
|
|
469
|
+
onClick={() => handleReinvite(row.id)}
|
|
470
|
+
className="text-xs text-blue-600 hover:text-blue-800 px-2 py-1 rounded border border-blue-200 hover:border-blue-400"
|
|
471
|
+
>
|
|
472
|
+
{copiedId === row.id ? 'Copied!' : 'Reinvite'}
|
|
473
|
+
</button>
|
|
474
|
+
)}
|
|
475
|
+
|
|
476
|
+
{/* Claimed (active invite) or Approved (access request) — no actions, visible in list */}
|
|
477
|
+
{(isClaimed || isApproved) && (
|
|
478
|
+
<span className="text-xs text-slate-400 px-2 py-1">—</span>
|
|
479
|
+
)}
|
|
480
|
+
|
|
481
|
+
{/* Pending access request — Approve + Deny */}
|
|
482
|
+
{isAccessReq && (
|
|
483
|
+
<>
|
|
484
|
+
<button
|
|
485
|
+
onClick={() => handleAction('approve', row.id)}
|
|
486
|
+
className="text-xs text-white bg-green-600 hover:bg-green-700 px-2 py-1 rounded"
|
|
487
|
+
>
|
|
488
|
+
Approve
|
|
489
|
+
</button>
|
|
490
|
+
<button
|
|
491
|
+
onClick={() => handleAction('deny', row.id)}
|
|
492
|
+
className="text-xs text-red-600 hover:text-red-800 px-2 py-1 rounded border border-red-200 hover:border-red-400"
|
|
493
|
+
>
|
|
494
|
+
Deny
|
|
495
|
+
</button>
|
|
496
|
+
</>
|
|
497
|
+
)}
|
|
498
|
+
|
|
341
499
|
</div>
|
|
342
500
|
</td>
|
|
343
501
|
</tr>
|
|
@@ -350,3 +508,26 @@ export function InviteManager({ accountId, defaultRoleSlug, scope }: InviteManag
|
|
|
350
508
|
</div>
|
|
351
509
|
)
|
|
352
510
|
}
|
|
511
|
+
|
|
512
|
+
// ─── SORT HEADER ──────────────────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
function SortTh({
|
|
515
|
+
label, col, current, dir, onSort
|
|
516
|
+
}: {
|
|
517
|
+
label: string
|
|
518
|
+
col: 'full_name' | 'email' | 'status' | 'created_at'
|
|
519
|
+
current: string
|
|
520
|
+
dir: 'asc' | 'desc'
|
|
521
|
+
onSort: (col: 'full_name' | 'email' | 'status' | 'created_at') => void
|
|
522
|
+
}) {
|
|
523
|
+
const active = current === col
|
|
524
|
+
return (
|
|
525
|
+
<th
|
|
526
|
+
className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider cursor-pointer select-none hover:text-slate-700"
|
|
527
|
+
onClick={() => onSort(col)}
|
|
528
|
+
>
|
|
529
|
+
{label}
|
|
530
|
+
{active ? (dir === 'asc' ? ' ↑' : ' ↓') : ' ↕'}
|
|
531
|
+
</th>
|
|
532
|
+
)
|
|
533
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* @stability stable
|
|
6
6
|
*
|
|
7
7
|
* System-level invite management page for system_admin.
|
|
8
|
-
* Shows
|
|
8
|
+
* Shows invites for the spine-system account scoped to system_admin role only.
|
|
9
9
|
* Rendered at /spine-framework/admin/access/invites
|
|
10
10
|
*
|
|
11
11
|
* @seeAlso src/components/shared/InviteManager.tsx
|
|
@@ -13,20 +13,31 @@
|
|
|
13
13
|
|
|
14
14
|
import React from 'react'
|
|
15
15
|
import { InviteManager } from '../../components/shared/InviteManager'
|
|
16
|
+
import { useAuth } from '../../contexts/AuthContext'
|
|
17
|
+
|
|
18
|
+
const FRAMEWORK_ROLES = [
|
|
19
|
+
{ slug: 'system_admin', name: 'System Admin' },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const FRAMEWORK_ROLE_SLUGS = FRAMEWORK_ROLES.map(r => r.slug).join(',')
|
|
16
23
|
|
|
17
24
|
export function InvitesPage() {
|
|
25
|
+
const { user } = useAuth()
|
|
26
|
+
|
|
18
27
|
return (
|
|
19
28
|
<div className="p-6 space-y-6">
|
|
20
29
|
<div>
|
|
21
30
|
<h1 className="text-2xl font-semibold text-slate-900">Invites & Access</h1>
|
|
22
31
|
<p className="mt-1 text-sm text-slate-500">
|
|
23
|
-
Manage
|
|
32
|
+
Manage system administrator invites for the Spine Framework.
|
|
24
33
|
</p>
|
|
25
34
|
</div>
|
|
26
35
|
|
|
27
36
|
<InviteManager
|
|
28
|
-
scope="
|
|
29
|
-
|
|
37
|
+
scope="account"
|
|
38
|
+
accountId={user?.account_id}
|
|
39
|
+
allowedRoles={FRAMEWORK_ROLES}
|
|
40
|
+
roleSlugsFilter={FRAMEWORK_ROLE_SLUGS}
|
|
30
41
|
/>
|
|
31
42
|
</div>
|
|
32
43
|
)
|
|
@@ -44,6 +44,7 @@ type DomainMatchChoice = 'join' | 'create_new' | null
|
|
|
44
44
|
|
|
45
45
|
interface InvitePreview {
|
|
46
46
|
email: string
|
|
47
|
+
full_name: string | null
|
|
47
48
|
account_name: string | null
|
|
48
49
|
role_name: string | null
|
|
49
50
|
kind: string
|
|
@@ -187,6 +188,8 @@ export function RegisterPage() {
|
|
|
187
188
|
} else {
|
|
188
189
|
setInvitePreview(result.data)
|
|
189
190
|
setEmail(result.data.email)
|
|
191
|
+
// Pre-fill name from invite stub (editable — user can correct it)
|
|
192
|
+
if (result.data.full_name) setFullName(result.data.full_name)
|
|
190
193
|
}
|
|
191
194
|
})()
|
|
192
195
|
}, [inviteToken])
|