spine-framework 0.3.94 → 0.3.95

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.
@@ -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, role_id, base_url } = body
454
- if (!email || !account_id || !role_id) {
455
- throw new Error('email, account_id, and role_id are required')
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: email.split('@')[0],
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, role_id, expires_at: expiresAt }
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:
@@ -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 ] ← always visible
12
- * ─────────────────────────────────────────
13
- * Invite list table with status + actions
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 defaultRoleSlug - Role to pre-assign on new invites ('support', 'member', etc.)
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
- defaultRoleSlug: string
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: 'bg-yellow-100 text-yellow-800',
92
+ pending: 'bg-yellow-100 text-yellow-800',
84
93
  'pending approval': 'bg-blue-100 text-blue-800',
85
- claimed: 'bg-blue-100 text-blue-800',
86
- approved: 'bg-green-100 text-green-800',
87
- expired: 'bg-slate-100 text-slate-600',
88
- blocked: 'bg-red-100 text-red-800',
89
- revoked: 'bg-slate-100 text-slate-500',
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, defaultRoleSlug, scope }: InviteManagerProps) {
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: defaultRoleSlug,
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
- // ── Copy link ──────────────────────────────────────────────────────────────
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="flex gap-3 items-end">
206
- <div className="flex-1">
207
- <label htmlFor="invite-email" className="block text-sm font-medium text-slate-700 mb-1">
208
- Invite by email
209
- </label>
210
- <input
211
- id="invite-email"
212
- type="email"
213
- required
214
- value={newEmail}
215
- onChange={e => setNewEmail(e.target.value)}
216
- placeholder="colleague@company.com"
217
- disabled={!accountId && scope !== 'system'}
218
- 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"
219
- />
220
- {!accountId && scope !== 'system' && (
221
- <p className="mt-1 text-xs text-slate-500">Select an account to enable invite generation.</p>
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
- <button
225
- type="submit"
226
- disabled={generating || !newEmail.trim() || (!accountId && scope !== 'system')}
227
- 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"
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
- ) : invites.length === 0 ? (
357
+ ) : filteredInvites.length === 0 ? (
254
358
  <div className="text-center py-12 text-slate-500">
255
- <p className="text-sm">No invites yet. Generate a link above to invite someone.</p>
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
- <th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Email</th>
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
- <th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Status</th>
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
- {invites.map(row => {
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.email}</td>
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
- {row.status === 'invited' && !row.blocked_at && (
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
- {/* Pending access request — approve / deny */}
315
- {row.status === 'pending' && row.kind === 'access_request' && (
438
+ {/* Expired — Reinvite + Revoke */}
439
+ {isExpired && (
316
440
  <>
317
441
  <button
318
- onClick={() => handleAction('approve', row.id)}
319
- className="text-xs text-white bg-green-600 hover:bg-green-700 px-2 py-1 rounded"
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
- Approve
445
+ {copiedId === row.id ? 'Copied!' : 'Reinvite'}
322
446
  </button>
323
447
  <button
324
- onClick={() => handleAction('deny', row.id)}
325
- className="text-xs text-red-600 hover:text-red-800 px-2 py-1 rounded border border-red-200 hover:border-red-400"
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
- Deny
451
+ Revoke
328
452
  </button>
329
453
  </>
330
454
  )}
331
455
 
332
- {/* Expired or blocked — re-invite */}
333
- {(displayStatus === 'expired' || displayStatus === 'blocked' || displayStatus === 'revoked') && (
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!' : 'Re-invite'}
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 all invites and access requests across all accounts.
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 invites and access requests across all accounts.
32
+ Manage system administrator invites for the Spine Framework.
24
33
  </p>
25
34
  </div>
26
35
 
27
36
  <InviteManager
28
- scope="system"
29
- defaultRoleSlug="support"
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])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.94",
3
+ "version": "0.3.95",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {