Files
cameleer-saas/ui/src/pages/tenant/TeamPage.tsx
hsiegeln 088bc34e67
All checks were successful
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m28s
fix(ui): extract meaningful error messages from API responses
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>
2026-04-26 21:10:28 +02:00

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>
);
}