spine-framework 0.3.53 → 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.
@@ -1,38 +1,74 @@
1
1
  -- =============================================================================
2
- -- 003_auth_user_trigger.sql
3
- -- Creates a people record whenever a new Supabase auth user signs up.
4
- -- Run AFTER 001_seed.sql (requires spine-system account and person type).
2
+ -- 005_auth_user_trigger.sql
3
+ -- Creates or updates a people record whenever a new Supabase auth user signs up.
4
+ -- Run AFTER 001_schema.sql (requires spine-system account and person type).
5
5
  --
6
- -- First user ever → system_admin role (bootstrap)
7
- -- Subsequent users → no role (assigned later via admin invite flow)
6
+ -- Resolution order on new auth.users insert:
7
+ --
8
+ -- 1. Stub match (invited/pending):
9
+ -- If a people row exists with matching email, status IN ('invited','pending'),
10
+ -- and no auth_uid yet → update it (set auth_uid, full_name).
11
+ -- - 'invited' stub → flip status to 'active' (invite = pre-approval)
12
+ -- - 'pending' stub → keep status 'pending' (access request needs approval)
13
+ --
14
+ -- 2. First user ever → system_admin on spine-system (bootstrap).
15
+ --
16
+ -- 3. Walk-up fallback → minimal active shell on spine-system with no role.
17
+ -- The frontend completes registration via the invites function
18
+ -- (creates account, assigns role, or creates access_request stub).
8
19
  -- =============================================================================
9
20
 
10
21
  CREATE OR REPLACE FUNCTION public.handle_new_auth_user()
11
22
  RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
12
23
  DECLARE
13
- v_account_id uuid;
14
- v_type_id uuid;
15
- v_role_id uuid;
16
- v_is_first boolean;
17
- v_design_schema jsonb;
18
- v_validation_schema jsonb;
24
+ v_account_id uuid;
25
+ v_type_id uuid;
26
+ v_role_id uuid;
27
+ v_is_first boolean;
28
+ v_design_schema jsonb;
29
+ v_validation_schema jsonb;
30
+ v_stub_id uuid;
31
+ v_stub_status text;
19
32
  BEGIN
33
+ -- ── Resolve core references ──────────────────────────────────────────────
20
34
  SELECT id INTO v_account_id FROM public.accounts WHERE slug = 'spine-system' LIMIT 1;
21
35
  SELECT id, design_schema, validation_schema
22
36
  INTO v_type_id, v_design_schema, v_validation_schema
23
37
  FROM public.types WHERE kind = 'person' AND slug = 'person' LIMIT 1;
24
38
 
25
39
  IF v_account_id IS NULL OR v_type_id IS NULL THEN
26
- RAISE WARNING 'handle_new_auth_user: spine-system account or person type not found — skipping people insert';
40
+ RAISE WARNING 'handle_new_auth_user: spine-system account or person type not found — skipping';
41
+ RETURN NEW;
42
+ END IF;
43
+
44
+ -- ── 1. Stub match: invited or pending people row for this email ───────────
45
+ SELECT id, status
46
+ INTO v_stub_id, v_stub_status
47
+ FROM public.people
48
+ WHERE email = NEW.email
49
+ AND status IN ('invited', 'pending')
50
+ AND auth_uid IS NULL
51
+ LIMIT 1;
52
+
53
+ IF v_stub_id IS NOT NULL THEN
54
+ UPDATE public.people SET
55
+ auth_uid = NEW.id,
56
+ full_name = COALESCE(NEW.raw_user_meta_data->>'full_name', full_name, split_part(NEW.email, '@', 1)),
57
+ -- invited = pre-approved; pending = still needs approval
58
+ status = CASE WHEN v_stub_status = 'invited' THEN 'active' ELSE 'pending' END,
59
+ updated_at = now()
60
+ WHERE id = v_stub_id;
61
+
27
62
  RETURN NEW;
28
63
  END IF;
29
64
 
30
- -- First person ever gets system_admin; subsequent users get no role
65
+ -- ── 2. First user bootstrap → system_admin ───────────────────────────────
31
66
  SELECT NOT EXISTS (SELECT 1 FROM public.people LIMIT 1) INTO v_is_first;
32
67
  IF v_is_first THEN
33
68
  SELECT id INTO v_role_id FROM public.roles WHERE slug = 'system_admin' LIMIT 1;
34
69
  END IF;
35
70
 
71
+ -- ── 3. Walk-up fallback → minimal shell (frontend completes via invites fn) ─
36
72
  INSERT INTO public.people (
37
73
  id,
38
74
  auth_uid,
@@ -41,6 +77,7 @@ BEGIN
41
77
  account_id,
42
78
  type_id,
43
79
  role_id,
80
+ status,
44
81
  is_active,
45
82
  design_schema,
46
83
  validation_schema
@@ -51,7 +88,8 @@ BEGIN
51
88
  COALESCE(NEW.raw_user_meta_data->>'full_name', split_part(NEW.email, '@', 1)),
52
89
  v_account_id,
53
90
  v_type_id,
54
- v_role_id,
91
+ v_role_id, -- NULL unless first user
92
+ 'active', -- frontend will update if this becomes an access_request
55
93
  true,
56
94
  v_design_schema,
57
95
  COALESCE(v_validation_schema, '{}'::jsonb)
@@ -4,6 +4,7 @@ import { useAuth } from './contexts/AuthContext'
4
4
  import { LoadingSpinner } from './components/ui/LoadingSpinner'
5
5
  import { LoginPage } from './pages/auth/LoginPage'
6
6
  import { RegisterPage } from './pages/auth/RegisterPage'
7
+ import { PendingApprovalPage } from './pages/auth/PendingApprovalPage'
7
8
  import { NotFoundPage } from './pages/NotFoundPage'
8
9
  import { DashboardPage } from './pages/DashboardPage'
9
10
  import { AppWrapper } from './components/AppWrapper'
@@ -37,6 +38,11 @@ function App() {
37
38
  <Route path="/register" element={<RegisterPage />} />
38
39
  <Route path="*" element={<Navigate to="/login" replace />} />
39
40
  </Routes>
41
+ ) : user.status === 'pending' ? (
42
+ // Access request submitted but not yet approved — block full app access
43
+ <Routes>
44
+ <Route path="*" element={<PendingApprovalPage />} />
45
+ </Routes>
40
46
  ) : (
41
47
  <AuthenticatedRouter />
42
48
  )}
@@ -33,6 +33,9 @@ const PromptConfigDetailPage = lazy(() => import('../../pages/admin/PromptConfig
33
33
  const APIKeysPage = lazy(() => import('../../pages/admin/APIKeysPage').then(m => ({ default: m.APIKeysPage })))
34
34
  const APIKeyDetailPage = lazy(() => import('../../pages/admin/APIKeyDetailPage').then(m => ({ default: m.APIKeyDetailPage })))
35
35
 
36
+ // Access pages
37
+ const InvitesPage = lazy(() => import('../../pages/admin/InvitesPage').then(m => ({ default: m.InvitesPage })))
38
+
36
39
  // Observability pages
37
40
  const ObservabilityDashboard = lazy(() => import('../../pages/admin/ObservabilityDashboard').then(m => ({ default: m.ObservabilityDashboard })))
38
41
  const PipelineExecutionsPage = lazy(() => import('../../pages/admin/PipelineExecutionsPage').then(m => ({ default: m.PipelineExecutionsPage })))
@@ -120,6 +123,9 @@ function AdminRoutes() {
120
123
  <Route path="configs/api-keys/:id" element={<APIKeyDetailPage />} />
121
124
  <Route path="configs/api-keys" element={<APIKeysPage />} />
122
125
 
126
+ {/* Access section */}
127
+ <Route path="access/invites" element={<InvitesPage />} />
128
+
123
129
  {/* Observability section */}
124
130
  <Route path="observability" element={<ObservabilityDashboard />} />
125
131
  <Route path="observability/dashboard" element={<ObservabilityDashboard />} />
@@ -29,7 +29,8 @@ import {
29
29
  Shield,
30
30
  MessageSquare,
31
31
  Key,
32
- TestTube
32
+ TestTube,
33
+ UserPlus
33
34
  } from "lucide-react"
34
35
  import {
35
36
  Sidebar,
@@ -64,6 +65,12 @@ const navigationSections = [
64
65
  { title: "API Keys", url: "/spine-framework/admin/configs/api-keys", icon: Key },
65
66
  ]
66
67
  },
68
+ {
69
+ title: "Access",
70
+ items: [
71
+ { title: "Invites", url: "/spine-framework/admin/access/invites", icon: UserPlus },
72
+ ]
73
+ },
67
74
  {
68
75
  title: "Runtime",
69
76
  items: [
@@ -0,0 +1,352 @@
1
+ /**
2
+ * @module src/components/shared/InviteManager
3
+ * @audience installer
4
+ * @layer frontend-component
5
+ * @stability stable
6
+ *
7
+ * Reusable invite and access-request management UI.
8
+ * Consumed by Cortex TeamPage, Portal TeamPage, and the Admin InvitesPage.
9
+ *
10
+ * Layout:
11
+ * [ Email input ] [ Generate Link button ] ← always visible
12
+ * ─────────────────────────────────────────
13
+ * Invite list table with status + actions
14
+ *
15
+ * @prop accountId - Scope invites to this account UUID.
16
+ * Omit (system_admin only) to show all accounts.
17
+ * @prop defaultRoleSlug - Role to pre-assign on new invites ('support', 'member', etc.)
18
+ * @prop scope - 'account' = scoped to one account | 'system' = all accounts
19
+ *
20
+ * @seeAlso functions/invites.ts (API)
21
+ * @seeAlso pages/admin/InvitesPage.tsx
22
+ * @seeAlso custom/apps/cortex/pages/team/TeamPage.tsx
23
+ * @seeAlso custom/apps/portal/pages/team/TeamPage.tsx
24
+ */
25
+
26
+ import React, { useState, useEffect, useCallback } from 'react'
27
+ import { apiFetch } from '../../lib/api'
28
+
29
+ // ─── TYPES ────────────────────────────────────────────────────────────────────
30
+
31
+ export type InviteScope = 'account' | 'system'
32
+
33
+ export interface InviteManagerProps {
34
+ accountId?: string
35
+ defaultRoleSlug: string
36
+ scope: InviteScope
37
+ }
38
+
39
+ interface InviteRow {
40
+ id: string
41
+ email: string
42
+ full_name: string
43
+ status: 'invited' | 'pending' | 'approved' | 'active' | 'inactive' | 'expired' | 'blocked'
44
+ kind: 'invite' | 'access_request'
45
+ expires_at: string | null
46
+ blocked_at: string | null
47
+ created_at: string
48
+ account: { id: string; slug: string; display_name: string } | null
49
+ role: { id: string; slug: string; name: string } | null
50
+ }
51
+
52
+ // ─── HELPERS ──────────────────────────────────────────────────────────────────
53
+
54
+ async function callInvitesApi(action: string, params: Record<string, any> = {}, method: 'GET' | 'POST' = 'GET') {
55
+ const base = '/.netlify/functions/invites'
56
+ if (method === 'GET') {
57
+ const qs = new URLSearchParams({ action, ...params }).toString()
58
+ const res = await apiFetch(`${base}?${qs}`)
59
+ return res.json()
60
+ }
61
+ const res = await apiFetch(`${base}?action=${action}`, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify(params),
65
+ })
66
+ return res.json()
67
+ }
68
+
69
+ function deriveDisplayStatus(row: InviteRow): string {
70
+ if (row.blocked_at) return 'blocked'
71
+ if (row.status === 'inactive') return 'revoked'
72
+ if (row.status === 'active' && row.kind === 'invite') return 'claimed'
73
+ if (row.status === 'active') return 'approved'
74
+ if (row.status === 'pending') return 'pending approval'
75
+ if (row.status === 'invited') {
76
+ if (row.expires_at && new Date(row.expires_at) < new Date()) return 'expired'
77
+ return 'pending'
78
+ }
79
+ return row.status
80
+ }
81
+
82
+ const STATUS_CLASSES: Record<string, string> = {
83
+ pending: 'bg-yellow-100 text-yellow-800',
84
+ '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',
90
+ }
91
+
92
+ function timeUntil(iso: string | null): string {
93
+ if (!iso) return '—'
94
+ const diff = new Date(iso).getTime() - Date.now()
95
+ if (diff <= 0) return 'expired'
96
+ const days = Math.floor(diff / 86400000)
97
+ const hours = Math.floor((diff % 86400000) / 3600000)
98
+ if (days > 0) return `${days}d`
99
+ return `${hours}h`
100
+ }
101
+
102
+ // ─── COMPONENT ────────────────────────────────────────────────────────────────
103
+
104
+ export function InviteManager({ accountId, defaultRoleSlug, scope }: InviteManagerProps) {
105
+ const [invites, setInvites] = useState<InviteRow[]>([])
106
+ const [loading, setLoading] = useState(true)
107
+ const [error, setError] = useState('')
108
+
109
+ const [newEmail, setNewEmail] = useState('')
110
+ const [generating, setGenerating] = useState(false)
111
+ const [generateError, setGenerateError] = useState('')
112
+ const [copiedId, setCopiedId] = useState<string | null>(null)
113
+
114
+ // ── Load invites ──────────────────────────────────────────────────────────
115
+ const loadInvites = useCallback(async () => {
116
+ setLoading(true)
117
+ setError('')
118
+ const params: Record<string, string> = {}
119
+ if (accountId) params.account_id = accountId
120
+
121
+ const result = await callInvitesApi('list-invites', params)
122
+ setLoading(false)
123
+ if (result.error) {
124
+ setError(result.error)
125
+ } else {
126
+ setInvites(result.data || [])
127
+ }
128
+ }, [accountId])
129
+
130
+ useEffect(() => { loadInvites() }, [loadInvites])
131
+
132
+ // ── Generate invite ────────────────────────────────────────────────────────
133
+ const handleGenerate = async (e: React.FormEvent) => {
134
+ e.preventDefault()
135
+ if (!newEmail.trim() || !accountId) return
136
+ setGenerating(true)
137
+ setGenerateError('')
138
+
139
+ const result = await callInvitesApi('create-invite', {
140
+ email: newEmail.trim(),
141
+ account_id: accountId,
142
+ role_slug: defaultRoleSlug,
143
+ base_url: window.location.origin,
144
+ }, 'POST')
145
+
146
+ setGenerating(false)
147
+
148
+ if (result.error) {
149
+ setGenerateError(result.error)
150
+ return
151
+ }
152
+
153
+ // Copy URL to clipboard
154
+ if (result.data?.invite_url) {
155
+ navigator.clipboard.writeText(result.data.invite_url).catch(() => {})
156
+ }
157
+
158
+ setNewEmail('')
159
+ await loadInvites()
160
+ }
161
+
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
+
183
+ const handleReinvite = async (personId: string) => {
184
+ const result = await callInvitesApi('reinvite', {
185
+ person_id: personId,
186
+ base_url: window.location.origin,
187
+ }, 'POST')
188
+ if (result.error) {
189
+ setError(result.error)
190
+ } else {
191
+ if (result.data?.invite_url) {
192
+ navigator.clipboard.writeText(result.data.invite_url).catch(() => {})
193
+ setCopiedId(personId)
194
+ setTimeout(() => setCopiedId(null), 2000)
195
+ }
196
+ await loadInvites()
197
+ }
198
+ }
199
+
200
+ // ── Render ─────────────────────────────────────────────────────────────────
201
+ return (
202
+ <div className="space-y-6">
203
+
204
+ {/* 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>
222
+ )}
223
+ </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>
231
+ </form>
232
+
233
+ {generateError && (
234
+ <div className="rounded-md bg-red-50 p-3">
235
+ <p className="text-sm text-red-800">{generateError}</p>
236
+ </div>
237
+ )}
238
+
239
+ <hr className="border-slate-200" />
240
+
241
+ {/* Error state */}
242
+ {error && (
243
+ <div className="rounded-md bg-red-50 p-3">
244
+ <p className="text-sm text-red-800">{error}</p>
245
+ </div>
246
+ )}
247
+
248
+ {/* Invite list */}
249
+ {loading ? (
250
+ <div className="flex items-center justify-center py-12">
251
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-slate-900" />
252
+ </div>
253
+ ) : invites.length === 0 ? (
254
+ <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>
256
+ </div>
257
+ ) : (
258
+ <div className="overflow-x-auto">
259
+ <table className="min-w-full divide-y divide-slate-200">
260
+ <thead>
261
+ <tr>
262
+ <th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Email</th>
263
+ {scope === 'system' && (
264
+ <th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Account</th>
265
+ )}
266
+ <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>
268
+ <th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Expires</th>
269
+ <th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">Actions</th>
270
+ </tr>
271
+ </thead>
272
+ <tbody className="divide-y divide-slate-100">
273
+ {invites.map(row => {
274
+ const displayStatus = deriveDisplayStatus(row)
275
+ return (
276
+ <tr key={row.id} className="hover:bg-slate-50">
277
+ <td className="px-4 py-3 text-sm text-slate-900">{row.email}</td>
278
+
279
+ {scope === 'system' && (
280
+ <td className="px-4 py-3 text-sm text-slate-600">
281
+ {row.account?.display_name ?? '—'}
282
+ </td>
283
+ )}
284
+
285
+ <td className="px-4 py-3 text-sm text-slate-600">
286
+ {row.role?.name ?? '—'}
287
+ </td>
288
+
289
+ <td className="px-4 py-3">
290
+ <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_CLASSES[displayStatus] ?? 'bg-slate-100 text-slate-600'}`}>
291
+ {displayStatus}
292
+ </span>
293
+ </td>
294
+
295
+ <td className="px-4 py-3 text-sm text-slate-500">
296
+ {row.status === 'invited' ? timeUntil(row.expires_at) : '—'}
297
+ </td>
298
+
299
+ <td className="px-4 py-3 text-right">
300
+ <div className="flex gap-2 justify-end">
301
+
302
+ {/* Pending unclaimed invite */}
303
+ {row.status === 'invited' && !row.blocked_at && (
304
+ <>
305
+ <button
306
+ onClick={() => handleAction('revoke-invite', row.id)}
307
+ className="text-xs text-slate-500 hover:text-red-600 px-2 py-1 rounded border border-slate-200 hover:border-red-300"
308
+ >
309
+ Revoke
310
+ </button>
311
+ </>
312
+ )}
313
+
314
+ {/* Pending access request — approve / deny */}
315
+ {row.status === 'pending' && row.kind === 'access_request' && (
316
+ <>
317
+ <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"
320
+ >
321
+ Approve
322
+ </button>
323
+ <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"
326
+ >
327
+ Deny
328
+ </button>
329
+ </>
330
+ )}
331
+
332
+ {/* Expired or blocked — re-invite */}
333
+ {(displayStatus === 'expired' || displayStatus === 'blocked' || displayStatus === 'revoked') && (
334
+ <button
335
+ onClick={() => handleReinvite(row.id)}
336
+ className="text-xs text-blue-600 hover:text-blue-800 px-2 py-1 rounded border border-blue-200 hover:border-blue-400"
337
+ >
338
+ {copiedId === row.id ? 'Copied!' : 'Re-invite'}
339
+ </button>
340
+ )}
341
+ </div>
342
+ </td>
343
+ </tr>
344
+ )
345
+ })}
346
+ </tbody>
347
+ </table>
348
+ </div>
349
+ )}
350
+ </div>
351
+ )
352
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @module src/pages/admin/InvitesPage
3
+ * @audience installer
4
+ * @layer frontend-page
5
+ * @stability stable
6
+ *
7
+ * System-level invite management page for system_admin.
8
+ * Shows all invites and access requests across all accounts.
9
+ * Rendered at /spine-framework/admin/access/invites
10
+ *
11
+ * @seeAlso src/components/shared/InviteManager.tsx
12
+ */
13
+
14
+ import React from 'react'
15
+ import { InviteManager } from '../../components/shared/InviteManager'
16
+
17
+ export function InvitesPage() {
18
+ return (
19
+ <div className="p-6 space-y-6">
20
+ <div>
21
+ <h1 className="text-2xl font-semibold text-slate-900">Invites & Access</h1>
22
+ <p className="mt-1 text-sm text-slate-500">
23
+ Manage invites and access requests across all accounts.
24
+ </p>
25
+ </div>
26
+
27
+ <InviteManager
28
+ scope="system"
29
+ defaultRoleSlug="support"
30
+ />
31
+ </div>
32
+ )
33
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @module src/pages/auth/PendingApprovalPage
3
+ * @audience installer
4
+ * @layer frontend-page
5
+ * @stability stable
6
+ *
7
+ * Shown to authenticated users whose people.status === 'pending'.
8
+ * They have successfully signed up but their access request has not yet
9
+ * been approved by an account administrator.
10
+ *
11
+ * @seeAlso src/App.tsx (pending gate)
12
+ * @seeAlso functions/invites.ts (approve action)
13
+ */
14
+
15
+ import React from 'react'
16
+ import { useAuth } from '../../contexts/AuthContext'
17
+
18
+ export function PendingApprovalPage() {
19
+ const { logout } = useAuth()
20
+
21
+ return (
22
+ <div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
23
+ <div className="max-w-md w-full text-center space-y-6">
24
+ <div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-amber-100">
25
+ <svg className="h-8 w-8 text-amber-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
26
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
27
+ </svg>
28
+ </div>
29
+
30
+ <div className="space-y-2">
31
+ <h1 className="text-2xl font-semibold text-slate-900">Awaiting approval</h1>
32
+ <p className="text-slate-600">
33
+ Your access request has been submitted. An account administrator will review
34
+ and approve your request shortly.
35
+ </p>
36
+ </div>
37
+
38
+ <div className="rounded-md bg-amber-50 border border-amber-200 p-4">
39
+ <p className="text-sm text-amber-800">
40
+ You'll be able to access the platform once your request has been approved.
41
+ Please check back later or contact your administrator directly.
42
+ </p>
43
+ </div>
44
+
45
+ <button
46
+ onClick={() => logout()}
47
+ className="text-sm text-slate-500 hover:text-slate-700 underline"
48
+ >
49
+ Sign out
50
+ </button>
51
+ </div>
52
+ </div>
53
+ )
54
+ }