feat: tenant portal — all 5 pages (dashboard, license, OIDC, team, settings)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,236 @@
|
||||
export function TeamPage() { return <div>TeamPage (TODO)</div>; }
|
||||
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 { Plus, Users } from 'lucide-react';
|
||||
import {
|
||||
useTenantTeam,
|
||||
useInviteTeamMember,
|
||||
useRemoveTeamMember,
|
||||
} 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 { toast } = useToast();
|
||||
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState('viewer');
|
||||
|
||||
const [removeTarget, setRemoveTarget] = 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) => (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={(e) => { e.stopPropagation(); setRemoveTarget(row); }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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: String(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: String(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 }} />
|
||||
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 }} />
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user