From d44ee4b977ee1dfc17a27ebbe02b00c72f600121 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:56:03 +0200 Subject: [PATCH] feat: add VendorAdminsPage with list, create/invite, remove, reset actions Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/Layout.tsx | 11 +- ui/src/pages/vendor/VendorAdminsPage.tsx | 399 +++++++++++++++++++++++ ui/src/router.tsx | 6 + 3 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/vendor/VendorAdminsPage.tsx diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index a9235df..dde1b0e 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -4,7 +4,7 @@ import { Sidebar, TopBar, } from '@cameleer/design-system'; -import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail, BarChart3, Server, ExternalLink, Key, Lock } from 'lucide-react'; +import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail, BarChart3, Server, ExternalLink, Key, Lock, UserCog } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useAuth } from '../auth/useAuth'; import { useScopes } from '../auth/useScopes'; @@ -157,6 +157,15 @@ export function Layout() { Auth Policy +
navigate('/vendor/admins')} + > + + Administrators +
window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')} diff --git a/ui/src/pages/vendor/VendorAdminsPage.tsx b/ui/src/pages/vendor/VendorAdminsPage.tsx new file mode 100644 index 0000000..291f699 --- /dev/null +++ b/ui/src/pages/vendor/VendorAdminsPage.tsx @@ -0,0 +1,399 @@ +import { useState } from 'react'; +import { + Alert, + AlertDialog, + Badge, + Button, + Card, + DataTable, + EmptyState, + FormField, + Input, + Spinner, + useToast, +} from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { Copy, Plus, ShieldCheck } from 'lucide-react'; +import { errorMessage } from '../../api/client'; +import { + useVendorAdminList, + useCreateVendorAdmin, + useRemoveVendorAdmin, + useResetVendorAdminPassword, + useResetVendorAdminMfa, +} from '../../api/vendor-admin-hooks'; +import { useEmailConnector } from '../../api/email-connector-hooks'; +import { useMe } from '../../api/hooks'; + +// DataTable requires T extends { id: string } +interface AdminRow { + id: string; + name: string; + email: string; +} + +function toRow(raw: { userId: string; name: string; email: string }): AdminRow { + return { id: raw.userId, name: raw.name || raw.email, email: raw.email }; +} + +export function VendorAdminsPage() { + const { data: rawAdmins, isLoading, isError } = useVendorAdminList(); + const { data: me } = useMe(); + const { data: emailConnector, isLoading: emailLoading } = useEmailConnector(); + const createAdmin = useCreateVendorAdmin(); + const removeAdmin = useRemoveVendorAdmin(); + const resetPassword = useResetVendorAdminPassword(); + const resetMfa = useResetVendorAdminMfa(); + const { toast } = useToast(); + + const currentUserId = me?.userId ?? null; + + // Add admin dialog state + const [showAdd, setShowAdd] = useState(false); + const [addEmail, setAddEmail] = useState(''); + const [addTempPassword, setAddTempPassword] = useState(''); + const [createdCredentials, setCreatedCredentials] = useState<{ email: string; tempPassword: string } | null>(null); + + // Remove dialog state + const [removeTarget, setRemoveTarget] = useState(null); + + // Reset password state + const [pwTarget, setPwTarget] = useState(null); + const [pwValue, setPwValue] = useState(''); + + // Reset MFA state + const [mfaTarget, setMfaTarget] = useState(null); + + const admins: AdminRow[] = (rawAdmins ?? []).map(toRow); + const emailConfigured = !emailLoading && emailConnector != null; + + function openAdd() { + setAddEmail(''); + setAddTempPassword(''); + setCreatedCredentials(null); + setShowAdd(true); + } + + function closeAdd() { + setShowAdd(false); + setAddEmail(''); + setAddTempPassword(''); + setCreatedCredentials(null); + } + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); + if (!addEmail.trim()) return; + try { + const result = await createAdmin.mutateAsync({ + email: addEmail.trim(), + tempPassword: emailConfigured ? undefined : addTempPassword || undefined, + }); + if (result.invited) { + toast({ title: `Invitation sent to ${addEmail}`, variant: 'success' }); + closeAdd(); + } else if (result.tempPassword) { + setCreatedCredentials({ email: addEmail.trim(), tempPassword: result.tempPassword }); + } else { + toast({ title: `Administrator ${addEmail} created`, variant: 'success' }); + closeAdd(); + } + } catch (err) { + toast({ title: 'Failed to add administrator', description: errorMessage(err), variant: 'error' }); + } + } + + async function handleRemove() { + if (!removeTarget) return; + try { + await removeAdmin.mutateAsync(removeTarget.id); + toast({ title: `Removed ${removeTarget.name}`, variant: 'success' }); + setRemoveTarget(null); + } catch (err) { + toast({ title: 'Remove failed', description: errorMessage(err), variant: 'error' }); + setRemoveTarget(null); + } + } + + const columns: Column[] = [ + { + key: 'name', + header: 'Name', + render: (_v, row) => ( + + {row.name} + {row.id === currentUserId && ( + + )} + + ), + }, + { + key: 'email', + header: 'Email', + render: (_v, row) => ( + {row.email} + ), + }, + { + key: 'id', + header: 'Actions', + render: (_v, row) => ( +
+ + + +
+ ), + }, + ]; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ + Could not fetch administrator list. Please refresh. + +
+ ); + } + + return ( +
+ {/* Header */} +
+

Platform Administrators

+ +
+ + {/* Add admin form */} + {showAdd && !createdCredentials && ( + + {emailLoading ? ( +
+ +
+ ) : ( +
+ {emailConfigured ? ( + + An invitation email will be sent to the new administrator. + + ) : ( + + Email connector is not configured. The new administrator will receive a temporary + password instead of an invitation link. + + )} + + + setAddEmail(e.target.value)} + required + /> + + + {!emailConfigured && ( + + setAddTempPassword(e.target.value)} + /> + + )} + +
+ + +
+
+ )} +
+ )} + + {/* Credential display after creation (no email connector) */} + {showAdd && createdCredentials && ( + + + Account created. Share these credentials securely — the temporary password will not be + shown again. + +
+
+ Email + {createdCredentials.email} +
+
+ Temporary password +
+ + {createdCredentials.tempPassword} + + +
+
+
+
+ +
+
+ )} + + {/* Admin table */} + {admins.length === 0 ? ( + } + title="No platform administrators" + description="Add an administrator to grant platform:admin access." + action={ + + } + /> + ) : ( + + )} + + {/* Remove confirmation dialog */} + setRemoveTarget(null)} + onConfirm={handleRemove} + title="Remove Administrator" + description={`Remove "${removeTarget?.name ?? ''}" from platform administrators? They will lose platform:admin access immediately.`} + confirmLabel="Remove" + cancelLabel="Cancel" + variant="danger" + loading={removeAdmin.isPending} + /> + + {/* Reset MFA inline card */} + {mfaTarget && ( + + + This will remove all MFA factors for this administrator. They will need to re-enroll if + MFA is required. + +
+ + +
+
+ )} + + {/* Reset password inline card */} + {pwTarget && ( + +
{ + e.preventDefault(); + if (pwValue.length < 8) { + toast({ title: 'Password must be at least 8 characters', variant: 'error' }); + return; + } + try { + await resetPassword.mutateAsync({ userId: pwTarget.id, password: pwValue }); + toast({ title: `Password reset for ${pwTarget.name}`, variant: 'success' }); + setPwTarget(null); + setPwValue(''); + } catch (err) { + toast({ title: 'Reset failed', description: errorMessage(err), variant: 'error' }); + } + }} + style={{ display: 'flex', flexDirection: 'column', gap: 16 }} + > + + setPwValue(e.target.value)} + required + minLength={8} + /> + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 0dd6359..b585ffc 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -19,6 +19,7 @@ import { VendorMetricsPage } from './pages/vendor/VendorMetricsPage'; import { EmailConfigPage } from './pages/vendor/EmailConfigPage'; import { LicenseVerifyPage } from './pages/vendor/LicenseVerifyPage'; import { AuthPolicyPage } from './pages/vendor/AuthPolicyPage'; +import { VendorAdminsPage } from './pages/vendor/VendorAdminsPage'; import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage'; import { TenantLicensePage } from './pages/tenant/TenantLicensePage'; import { SsoPage } from './pages/tenant/SsoPage'; @@ -120,6 +121,11 @@ export function AppRouter() { } /> + }> + + + } /> {/* Tenant portal */} } />