Files
server-26/drb-frontend/app/settings/members/page.tsx
T
Logan CusanoandClaude Opus 5.5 bf206334e3 fix(frontend): finish the token migration — raw grey classes -> theme tokens
UI_REDESIGN chunk 4 was never finished: 39 app/component files still used
raw Tailwind greys, kept readable in light mode only by the !important
override sheet. In dark mode text-gray-600 is ~2.3:1 on surface, and the
light override maps it to #94a3b8 (~2.6:1 on white) — unreadable in both.

Mechanical mapping (886 classes): gray-100/200 -> ink, 300/400 -> ink-2,
500/600/700 -> ink-muted, bg 950/900/800 -> page/surface/raised,
border 800 -> line, 700/600 -> line-strong, plus hover/divide/placeholder.
Alpha suffixes kept (tokens are color-mix, so /60 works). bg-gray-700,
text-white and marketing pages left alone. text-[9px] -> text-[10px].

Frontend unverified: no Node on the authoring box (#207).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:29:13 -04:00

198 lines
8.3 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
import type { UserRecord, UserRole } from "@/lib/types";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonRow } from "@/components/ui/Skeleton";
const ROLE_TONE: Record<UserRole, "brand" | "success" | "neutral"> = {
admin: "brand",
operator: "success",
viewer: "neutral",
};
const ROLE_LABEL: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (u: UserRecord) => void }) {
const [email, setEmail] = useState("");
const [role, setRole] = useState<UserRole>("viewer");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const created = await c2api.createUser({ email, role });
onCreated(created);
if (created.invite_link) setInviteLink(created.invite_link);
else onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
if (inviteLink) {
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-md space-y-4">
<h2 className="text-white font-semibold">Member invited</h2>
<p className="text-xs text-ink-2">Share this one-time invite link so they can set their password. It expires after use.</p>
<div className="bg-raised border border-line-strong rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div>
<Button onClick={onClose} fullWidth>Done</Button>
</Card>
</div>
);
}
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-md">
<h2 className="text-white font-semibold mb-4">Invite a member</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-ink-2 block mb-1">Email</label>
<input
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="teammate@example.com"
/>
</div>
<div>
<label className="text-xs text-ink-2 block mb-1">Role</label>
<select
value={role} onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option>
<option value="viewer">Viewer — read-only</option>
</select>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1">
<Button type="submit" disabled={saving} fullWidth>{saving ? "Sending…" : "Send invite"}</Button>
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
</div>
</form>
</Card>
</div>
);
}
export default function MembersSettingsPage() {
const { user } = useAuth();
const [users, setUsers] = useState<UserRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showInvite, setShowInvite] = useState(false);
const [savingUid, setSavingUid] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setUsers(await c2api.listUsers());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
async function handleRoleChange(u: UserRecord, role: UserRole) {
setSavingUid(u.uid);
try {
const updated = await c2api.updateUser(u.uid, { role, owned_node_ids: role === "operator" ? u.owned_node_ids : [] });
setUsers((prev) => prev.map((x) => (x.uid === u.uid ? { ...x, ...updated } : x)));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSavingUid(null);
}
}
return (
<div className="space-y-4">
{showInvite && (
<InviteModal onClose={() => setShowInvite(false)} onCreated={(u) => setUsers((prev) => [...prev, u])} />
)}
<div className="flex items-center justify-between">
<p className="text-sm text-ink-muted">
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
</p>
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
</div>
{error && <ErrorBanner message={error} />}
{!loading && users.length === 0 ? (
<EmptyState title="No members yet" description="Invite your team to give them dashboard access." />
) : (
<Card padding="none" className="overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
<th className="px-4 py-3 text-left">Member</th>
<th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th>
<th className="px-4 py-3 text-left hidden md:table-cell">Status</th>
</tr>
</thead>
<tbody>
{loading
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
: users.map((u) => (
<tr key={u.uid} className="border-b border-line last:border-0">
<td className="px-4 py-3">
<p className="text-white">{u.display_name || u.email}</p>
{u.display_name && <p className="text-ink-muted text-xs">{u.email}</p>}
</td>
<td className="px-4 py-3">
{u.uid === user?.uid ? (
<Badge tone={ROLE_TONE[u.role]}>{ROLE_LABEL[u.role]}</Badge>
) : (
<select
value={u.role}
disabled={savingUid === u.uid}
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
className="bg-raised border border-line-strong rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
)}
</td>
<td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">
{u.role === "operator" ? u.owned_node_ids.length : "—"}
</td>
<td className="px-4 py-3 hidden md:table-cell">
{u.disabled ? <Badge tone="danger">Disabled</Badge> : <Badge tone="success">Active</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
<p className="text-xs text-ink-muted font-mono">
Need to disable or delete a member? Use the full user admin panel under Admin → Users.
</p>
</div>
);
}