spine-framework 0.3.54 → 0.3.56
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/dev.ts +19 -0
- package/.framework/cli/commands/install-app.ts +89 -1
- package/.framework/functions/auth.ts +2 -0
- package/.framework/functions/invites.ts +655 -0
- package/.framework/migrations/005_auth_user_trigger.sql +52 -14
- package/.framework/migrations/016_invites.sql +27 -0
- package/.framework/src/App.tsx +6 -0
- package/.framework/src/apps/admin/index.tsx +6 -0
- package/.framework/src/components/admin/AdminSidebar.tsx +8 -1
- package/.framework/src/components/shared/InviteManager.tsx +352 -0
- package/.framework/src/pages/admin/InvitesPage.tsx +33 -0
- package/.framework/src/pages/auth/PendingApprovalPage.tsx +54 -0
- package/.framework/src/pages/auth/RegisterPage.tsx +433 -61
- package/.framework/src/types/auth.ts +2 -0
- package/config/vite.config.ts +4 -0
- package/package.json +3 -1
- package/scripts/assemble-frontend.sh +7 -0
- package/spine.config.json +20 -0
|
@@ -1,38 +1,74 @@
|
|
|
1
1
|
-- =============================================================================
|
|
2
|
-
--
|
|
3
|
-
-- Creates a people record whenever a new Supabase auth user signs up.
|
|
4
|
-
-- Run AFTER
|
|
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
|
-
--
|
|
7
|
-
--
|
|
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
|
|
14
|
-
v_type_id
|
|
15
|
-
v_role_id
|
|
16
|
-
v_is_first
|
|
17
|
-
v_design_schema
|
|
18
|
-
v_validation_schema
|
|
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
|
|
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
|
|
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)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- Migration 016: Invite system indexes
|
|
2
|
+
-- Invite and access_request stubs are stored as people rows with status
|
|
3
|
+
-- 'invited' or 'pending' and invite metadata in the data jsonb column.
|
|
4
|
+
--
|
|
5
|
+
-- people.status values:
|
|
6
|
+
-- active — normal active person
|
|
7
|
+
-- inactive — deactivated
|
|
8
|
+
-- invited — admin-generated invite stub, unclaimed (pre-approval by definition)
|
|
9
|
+
-- pending — walk-up access request stub, awaiting member_admin approval
|
|
10
|
+
--
|
|
11
|
+
-- Invite metadata stored in people.data:
|
|
12
|
+
-- invite_token text — 16-char random lookup key (URL: /register?t=TOKEN)
|
|
13
|
+
-- invite_kind text — 'invite' | 'access_request'
|
|
14
|
+
-- invite_expires_at text — ISO timestamp
|
|
15
|
+
-- invite_attempts integer — failed validation attempts
|
|
16
|
+
-- invite_blocked_at text — ISO timestamp, set after too many attempts
|
|
17
|
+
-- invite_invited_by uuid — person UUID of the inviter
|
|
18
|
+
|
|
19
|
+
-- Fast token lookup
|
|
20
|
+
CREATE INDEX IF NOT EXISTS people_invite_token_idx
|
|
21
|
+
ON public.people ((data->>'invite_token'))
|
|
22
|
+
WHERE data->>'invite_token' IS NOT NULL;
|
|
23
|
+
|
|
24
|
+
-- Efficient scoped invite list queries
|
|
25
|
+
CREATE INDEX IF NOT EXISTS people_invite_kind_account_idx
|
|
26
|
+
ON public.people (account_id, status)
|
|
27
|
+
WHERE data->>'invite_kind' IS NOT NULL;
|
package/.framework/src/App.tsx
CHANGED
|
@@ -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
|
+
}
|