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>
198 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|