feat: add VendorAdminsPage with list, create/invite, remove, reset actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 14:56:03 +02:00
parent 5d1d263c74
commit d44ee4b977
3 changed files with 415 additions and 1 deletions

View File

@@ -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() {
<Lock size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
Auth Policy
</div>
<div
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
fontWeight: isActive(location, '/vendor/admins') ? 600 : 400,
color: isActive(location, '/vendor/admins') ? 'var(--amber)' : 'var(--text-muted)' }}
onClick={() => navigate('/vendor/admins')}
>
<UserCog size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
Administrators
</div>
<div
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}

399
ui/src/pages/vendor/VendorAdminsPage.tsx vendored Normal file
View File

@@ -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<AdminRow | null>(null);
// Reset password state
const [pwTarget, setPwTarget] = useState<AdminRow | null>(null);
const [pwValue, setPwValue] = useState('');
// Reset MFA state
const [mfaTarget, setMfaTarget] = useState<AdminRow | null>(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<AdminRow>[] = [
{
key: 'name',
header: 'Name',
render: (_v, row) => (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{row.name}
{row.id === currentUserId && (
<Badge label="You" color="primary" />
)}
</span>
),
},
{
key: 'email',
header: 'Email',
render: (_v, row) => (
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>{row.email}</span>
),
},
{
key: 'id',
header: 'Actions',
render: (_v, row) => (
<div style={{ display: 'flex', gap: 6 }}>
<Button
variant="secondary"
size="sm"
onClick={(e) => { e.stopPropagation(); setPwTarget(row); setPwValue(''); }}
>
Reset Password
</Button>
<Button
variant="secondary"
size="sm"
onClick={(e) => { e.stopPropagation(); setMfaTarget(row); }}
>
Reset MFA
</Button>
<Button
variant="danger"
size="sm"
disabled={row.id === currentUserId}
onClick={(e) => { e.stopPropagation(); setRemoveTarget(row); }}
>
Remove
</Button>
</div>
),
},
];
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spinner />
</div>
);
}
if (isError) {
return (
<div style={{ padding: 24 }}>
<Alert variant="error" title="Failed to load administrators">
Could not fetch administrator list. Please refresh.
</Alert>
</div>
);
}
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Platform Administrators</h1>
<Button variant="primary" onClick={openAdd}>
<Plus size={16} style={{ marginRight: 6, verticalAlign: -3 }} />
Add Administrator
</Button>
</div>
{/* Add admin form */}
{showAdd && !createdCredentials && (
<Card title="Add Platform Administrator">
{emailLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spinner />
</div>
) : (
<form onSubmit={handleAdd} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{emailConfigured ? (
<Alert variant="info">
An invitation email will be sent to the new administrator.
</Alert>
) : (
<Alert variant="warning">
Email connector is not configured. The new administrator will receive a temporary
password instead of an invitation link.
</Alert>
)}
<FormField label="Email address" htmlFor="add-admin-email">
<Input
id="add-admin-email"
type="email"
placeholder="admin@example.com"
value={addEmail}
onChange={(e) => setAddEmail(e.target.value)}
required
/>
</FormField>
{!emailConfigured && (
<FormField label="Temporary password" htmlFor="add-admin-pw">
<Input
id="add-admin-pw"
type="password"
placeholder="Leave blank to auto-generate"
value={addTempPassword}
onChange={(e) => setAddTempPassword(e.target.value)}
/>
</FormField>
)}
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={createAdmin.isPending}>
{emailConfigured ? 'Send Invite' : 'Create'}
</Button>
<Button type="button" variant="secondary" onClick={closeAdd}>
Cancel
</Button>
</div>
</form>
)}
</Card>
)}
{/* Credential display after creation (no email connector) */}
{showAdd && createdCredentials && (
<Card title="Administrator Created">
<Alert variant="success">
Account created. Share these credentials securely the temporary password will not be
shown again.
</Alert>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 500 }}>Email</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>{createdCredentials.email}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 500 }}>Temporary password</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
{createdCredentials.tempPassword}
</span>
<Button
variant="secondary"
size="sm"
onClick={() => {
navigator.clipboard.writeText(createdCredentials.tempPassword).then(() =>
toast({ title: 'Copied to clipboard', variant: 'success' })
);
}}
>
<Copy size={14} style={{ marginRight: 4, verticalAlign: -2 }} />
Copy
</Button>
</div>
</div>
</div>
<div style={{ marginTop: 16 }}>
<Button variant="primary" onClick={closeAdd}>
Done
</Button>
</div>
</Card>
)}
{/* Admin table */}
{admins.length === 0 ? (
<EmptyState
icon={<ShieldCheck size={32} />}
title="No platform administrators"
description="Add an administrator to grant platform:admin access."
action={
<Button variant="primary" onClick={openAdd}>
<Plus size={16} style={{ marginRight: 6, verticalAlign: -3 }} />
Add Administrator
</Button>
}
/>
) : (
<DataTable columns={columns} data={admins} />
)}
{/* Remove confirmation dialog */}
<AlertDialog
open={removeTarget !== null}
onClose={() => 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 && (
<Card title={`Reset MFA — ${mfaTarget.name || mfaTarget.email}`}>
<Alert variant="warning">
This will remove all MFA factors for this administrator. They will need to re-enroll if
MFA is required.
</Alert>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<Button
variant="danger"
loading={resetMfa.isPending}
onClick={async () => {
try {
await resetMfa.mutateAsync(mfaTarget.id);
toast({ title: `MFA reset for ${mfaTarget.name || mfaTarget.email}`, variant: 'success' });
setMfaTarget(null);
} catch (err) {
toast({ title: 'Failed to reset MFA', description: errorMessage(err), variant: 'error' });
}
}}
>
Reset MFA
</Button>
<Button variant="secondary" onClick={() => setMfaTarget(null)}>Cancel</Button>
</div>
</Card>
)}
{/* Reset password inline card */}
{pwTarget && (
<Card title={`Reset Password — ${pwTarget.name}`}>
<form
onSubmit={async (e) => {
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 }}
>
<FormField label="New password" htmlFor="admin-reset-pw">
<Input
id="admin-reset-pw"
type="password"
placeholder="Min. 8 characters"
value={pwValue}
onChange={(e) => setPwValue(e.target.value)}
required
minLength={8}
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={resetPassword.isPending}>
Reset Password
</Button>
<Button type="button" variant="secondary" onClick={() => { setPwTarget(null); setPwValue(''); }}>
Cancel
</Button>
</div>
</form>
</Card>
)}
</div>
);
}

View File

@@ -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() {
<AuthPolicyPage />
</RequireScope>
} />
<Route path="/vendor/admins" element={
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
<VendorAdminsPage />
</RequireScope>
} />
{/* Tenant portal */}
<Route path="/tenant" element={<TenantDashboardPage />} />