Introduces ApiError class in client.ts that parses Spring Boot error bodies to extract human-readable messages (message, error, detail fields). Adds errorMessage() helper used by all toast descriptions instead of raw String(err) which dumped JSON blobs to the user. Affected: all 10 page components that display error toasts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
import { useState } from 'react';
|
|
import { errorMessage } from '../../api/client';
|
|
import {
|
|
Alert,
|
|
AlertDialog,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
DataTable,
|
|
EmptyState,
|
|
FormField,
|
|
Input,
|
|
Spinner,
|
|
useToast,
|
|
} from '@cameleer/design-system';
|
|
import type { Column } from '@cameleer/design-system';
|
|
import { Plus, Users } from 'lucide-react';
|
|
import {
|
|
useTenantTeam,
|
|
useInviteTeamMember,
|
|
useRemoveTeamMember,
|
|
useResetTeamMemberPassword,
|
|
useResetTeamMemberMfa,
|
|
} from '../../api/tenant-hooks';
|
|
import styles from '../../styles/platform.module.css';
|
|
|
|
// DataTable requires T extends { id: string }
|
|
interface TeamMember {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
role: string;
|
|
}
|
|
|
|
function toMember(raw: Record<string, unknown>): TeamMember {
|
|
return {
|
|
id: String(raw['id'] ?? raw['userId'] ?? ''),
|
|
name: String(raw['name'] ?? raw['username'] ?? '—'),
|
|
email: String(raw['email'] ?? '—'),
|
|
role: String(raw['role'] ?? raw['orgRole'] ?? '—'),
|
|
};
|
|
}
|
|
|
|
const ROLES = [
|
|
{ id: 'owner', label: 'Owner' },
|
|
{ id: 'operator', label: 'Operator' },
|
|
{ id: 'viewer', label: 'Viewer' },
|
|
];
|
|
|
|
function roleColor(role: string): 'primary' | 'success' | 'warning' | 'auto' {
|
|
switch (role?.toLowerCase()) {
|
|
case 'owner': return 'primary';
|
|
case 'operator': return 'success';
|
|
case 'viewer': return 'warning';
|
|
default: return 'auto';
|
|
}
|
|
}
|
|
|
|
export function TeamPage() {
|
|
const { data: rawTeam, isLoading, isError } = useTenantTeam();
|
|
const inviteMember = useInviteTeamMember();
|
|
const removeMember = useRemoveTeamMember();
|
|
const resetPassword = useResetTeamMemberPassword();
|
|
const resetMfa = useResetTeamMemberMfa();
|
|
const { toast } = useToast();
|
|
|
|
const [showInvite, setShowInvite] = useState(false);
|
|
const [inviteEmail, setInviteEmail] = useState('');
|
|
const [inviteRole, setInviteRole] = useState('viewer');
|
|
|
|
const [removeTarget, setRemoveTarget] = useState<TeamMember | null>(null);
|
|
const [pwTarget, setPwTarget] = useState<TeamMember | null>(null);
|
|
const [pwValue, setPwValue] = useState('');
|
|
const [mfaResetTarget, setMfaResetTarget] = useState<TeamMember | null>(null);
|
|
|
|
const team: TeamMember[] = (rawTeam ?? []).map(toMember).filter((m) => m.id !== '');
|
|
|
|
const columns: Column<TeamMember>[] = [
|
|
{
|
|
key: 'name',
|
|
header: 'Name',
|
|
render: (_v, row) => row.name,
|
|
},
|
|
{
|
|
key: 'email',
|
|
header: 'Email',
|
|
render: (_v, row) => (
|
|
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>{row.email}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'role',
|
|
header: 'Role',
|
|
render: (_v, row) => <Badge label={row.role} color={roleColor(row.role)} />,
|
|
},
|
|
{
|
|
key: 'id',
|
|
header: 'Actions',
|
|
render: (_v, row) => (
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={(e) => { e.stopPropagation(); setPwTarget(row); setPwValue(''); }}
|
|
>
|
|
Reset Password
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setMfaResetTarget(row)}
|
|
>
|
|
Reset MFA
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
onClick={(e) => { e.stopPropagation(); setRemoveTarget(row); }}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
async function handleInvite(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!inviteEmail.trim()) return;
|
|
try {
|
|
await inviteMember.mutateAsync({ email: inviteEmail.trim(), roleId: inviteRole });
|
|
toast({ title: `Invited ${inviteEmail}`, variant: 'success' });
|
|
setInviteEmail('');
|
|
setInviteRole('viewer');
|
|
setShowInvite(false);
|
|
} catch (err) {
|
|
toast({ title: 'Invite failed', description: errorMessage(err), variant: 'error' });
|
|
}
|
|
}
|
|
|
|
async function handleRemove() {
|
|
if (!removeTarget) return;
|
|
try {
|
|
await removeMember.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);
|
|
}
|
|
}
|
|
|
|
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 team">
|
|
Could not fetch team members. 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 }}>Team</h1>
|
|
<Button variant="primary" onClick={() => setShowInvite((v) => !v)}>
|
|
<Plus size={16} style={{ marginRight: 6, verticalAlign: -3 }} />
|
|
Invite Member
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Inline invite form */}
|
|
{showInvite && (
|
|
<Card title="Invite Team Member">
|
|
<form onSubmit={handleInvite} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
<FormField label="Email address" htmlFor="invite-email">
|
|
<Input
|
|
id="invite-email"
|
|
type="email"
|
|
placeholder="colleague@example.com"
|
|
value={inviteEmail}
|
|
onChange={(e) => setInviteEmail(e.target.value)}
|
|
required
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField label="Role" htmlFor="invite-role">
|
|
<select
|
|
id="invite-role"
|
|
value={inviteRole}
|
|
onChange={(e) => setInviteRole(e.target.value)}
|
|
style={{
|
|
width: '100%',
|
|
padding: '6px 10px',
|
|
borderRadius: 6,
|
|
border: '1px solid var(--border)',
|
|
background: 'var(--bg-surface)',
|
|
color: 'var(--text-primary)',
|
|
fontSize: '0.875rem',
|
|
}}
|
|
>
|
|
{ROLES.map((r) => (
|
|
<option key={r.id} value={r.id}>{r.label}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<Button type="submit" variant="primary" loading={inviteMember.isPending}>
|
|
Send Invite
|
|
</Button>
|
|
<Button type="button" variant="secondary" onClick={() => setShowInvite(false)}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Team table */}
|
|
{team.length === 0 ? (
|
|
<EmptyState
|
|
icon={<Users size={32} />}
|
|
title="No team members yet"
|
|
description="Invite colleagues to collaborate on this tenant."
|
|
action={
|
|
<Button variant="primary" onClick={() => setShowInvite(true)}>
|
|
<Plus size={16} style={{ marginRight: 6, verticalAlign: -3 }} />
|
|
Invite Member
|
|
</Button>
|
|
}
|
|
/>
|
|
) : (
|
|
<DataTable columns={columns} data={team} />
|
|
)}
|
|
|
|
{/* Remove confirmation dialog */}
|
|
<AlertDialog
|
|
open={removeTarget !== null}
|
|
onClose={() => setRemoveTarget(null)}
|
|
onConfirm={handleRemove}
|
|
title="Remove Team Member"
|
|
description={`Are you sure you want to remove "${removeTarget?.name ?? ''}" from the team? They will lose access immediately.`}
|
|
confirmLabel="Remove"
|
|
cancelLabel="Cancel"
|
|
variant="danger"
|
|
loading={removeMember.isPending}
|
|
/>
|
|
|
|
{/* Reset MFA inline form */}
|
|
{mfaResetTarget && (
|
|
<Card title={`Reset MFA for ${mfaResetTarget.name || mfaResetTarget.email}`}>
|
|
<Alert variant="warning">
|
|
This will remove all MFA factors for this user. They will need to re-enroll if MFA is required.
|
|
</Alert>
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
|
<Button
|
|
variant="danger"
|
|
onClick={async () => {
|
|
try {
|
|
await resetMfa.mutateAsync(mfaResetTarget.id);
|
|
toast({ title: `MFA reset for ${mfaResetTarget.name || mfaResetTarget.email}`, variant: 'success' });
|
|
setMfaResetTarget(null);
|
|
} catch (err) {
|
|
toast({ title: 'Failed to reset MFA', description: errorMessage(err), variant: 'error' });
|
|
}
|
|
}}
|
|
loading={resetMfa.isPending}
|
|
>
|
|
Confirm Reset MFA
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => setMfaResetTarget(null)}>Cancel</Button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Reset password inline form */}
|
|
{pwTarget && (
|
|
<Card title={`Reset password for ${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="reset-pw">
|
|
<Input
|
|
id="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)}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|