1f17b6c0d2
Introduces a full user management system with three roles (admin, operator,
viewer), an audit log, and per-session login history.
Backend:
- app/internal/audit.py: write_audit() helper → audit_log Firestore collection
- app/internal/auth.py: get_role() helper; require_admin_token accepts both
legacy admin:true claim and new role:"admin" claim for backward compat
- app/routers/users.py: CRUD under /admin/users — list, create (returns
one-time invite link), get (with sessions), patch role/nodes/name,
disable, enable, delete; operator role requires ≥1 owned node
- app/routers/links.py: POST /auth/session records sign-in events to
user_sessions Firestore collection
- app/routers/admin.py: GET /admin/audit paginated endpoint
- app/main.py: register users router
Frontend:
- AuthProvider: exposes role, isAdmin, isOperator, ownedNodeIds from claims
- Nav: role-gated links — viewers get dashboard/calls/incidents/map/alerts/
trips; operators add nodes/systems/tokens; admins add admin
- admin/page.tsx: new Users tab (list table, create modal, inline edit panel
with role/nodes editor, disable/enable/delete, login history) and Audit
Log tab (paginated, color-coded actions)
- login/page.tsx: calls recordSession() on email and Google sign-in
- nodes, systems, tokens pages: role guards redirect viewers to dashboard
- profile/page.tsx: shows accurate role badge and label
- lib/types.ts: UserRole, UserRecord, UserSession, AuditEntry types
- lib/c2api.ts: user management methods + recordSession
Firestore collections added: user_profiles, audit_log, user_sessions
Firebase custom claims schema: { role, owned_node_ids, admin (legacy) }
223 lines
7.7 KiB
TypeScript
223 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useAuth } from "@/components/AuthProvider";
|
|
import { c2api } from "@/lib/c2api";
|
|
|
|
interface LinkStatus {
|
|
linked: boolean;
|
|
discord_user_id?: string;
|
|
discord_username?: string;
|
|
linked_at?: string;
|
|
}
|
|
|
|
function fmtDate(iso: string) {
|
|
return new Date(iso).toLocaleDateString("en-US", {
|
|
month: "short", day: "numeric", year: "numeric",
|
|
});
|
|
}
|
|
|
|
function Initials({ name }: { name: string }) {
|
|
const parts = name.trim().split(/\s+/);
|
|
const letters = parts.length >= 2
|
|
? parts[0][0] + parts[parts.length - 1][0]
|
|
: name.slice(0, 2);
|
|
return (
|
|
<div className="w-16 h-16 rounded-full bg-indigo-700 flex items-center justify-center text-white text-xl font-bold select-none">
|
|
{letters.toUpperCase()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ProfilePage() {
|
|
const { user, isAdmin, role, signOut } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const [linkStatus, setLinkStatus] = useState<LinkStatus | null>(null);
|
|
const [linkLoading, setLinkLoading] = useState(true);
|
|
const [code, setCode] = useState<string | null>(null);
|
|
const [codeExpiry, setCodeExpiry] = useState<number | null>(null);
|
|
const [generating, setGenerating] = useState(false);
|
|
const [unlinking, setUnlinking] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
c2api.getLinkStatus()
|
|
.then(setLinkStatus)
|
|
.catch(() => setLinkStatus({ linked: false }))
|
|
.finally(() => setLinkLoading(false));
|
|
}, [user]);
|
|
|
|
async function generateCode() {
|
|
setGenerating(true);
|
|
try {
|
|
const res = await c2api.generateLinkCode();
|
|
if (res.already_linked) {
|
|
setLinkStatus((prev) => prev ? { ...prev, linked: true } : prev);
|
|
} else if (res.code) {
|
|
setCode(res.code);
|
|
setCodeExpiry(res.expires_minutes ?? 15);
|
|
}
|
|
} finally {
|
|
setGenerating(false);
|
|
}
|
|
}
|
|
|
|
async function unlink() {
|
|
setUnlinking(true);
|
|
try {
|
|
await c2api.unlinkDiscord();
|
|
setLinkStatus({ linked: false });
|
|
setCode(null);
|
|
} finally {
|
|
setUnlinking(false);
|
|
}
|
|
}
|
|
|
|
async function handleSignOut() {
|
|
await signOut();
|
|
router.push("/login");
|
|
}
|
|
|
|
if (!user) return null;
|
|
|
|
const displayName = user.displayName || user.email || "Account";
|
|
|
|
return (
|
|
<div className="max-w-lg space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4">
|
|
<Initials name={displayName} />
|
|
<div>
|
|
<h1 className="text-white text-xl font-bold">{displayName}</h1>
|
|
{user.displayName && user.email && (
|
|
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p>
|
|
)}
|
|
{role && (
|
|
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
|
|
role === "admin" ? "bg-indigo-900 text-indigo-300" :
|
|
role === "operator" ? "bg-green-900 text-green-300" :
|
|
"bg-gray-800 text-gray-400"
|
|
}`}>
|
|
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Firebase account */}
|
|
<section className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
|
<div className="px-4 py-3">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Account</p>
|
|
<div className="space-y-2">
|
|
<Row label="Email" value={user.email ?? "—"} />
|
|
<Row label="UID" value={user.uid} mono truncate />
|
|
<Row label="Role" value={role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} />
|
|
{user.metadata.creationTime && (
|
|
<Row label="Joined" value={fmtDate(user.metadata.creationTime)} />
|
|
)}
|
|
{user.metadata.lastSignInTime && (
|
|
<Row label="Last sign-in" value={fmtDate(user.metadata.lastSignInTime)} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Discord linking */}
|
|
<section className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
|
<div className="px-4 py-3">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-3">Discord</p>
|
|
|
|
{linkLoading ? (
|
|
<p className="text-gray-500 text-sm">Loading…</p>
|
|
) : linkStatus?.linked ? (
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
{linkStatus.discord_username && (
|
|
<Row label="Username" value={`@${linkStatus.discord_username}`} />
|
|
)}
|
|
{linkStatus.discord_user_id && (
|
|
<Row label="User ID" value={linkStatus.discord_user_id} mono />
|
|
)}
|
|
{linkStatus.linked_at && (
|
|
<Row label="Linked" value={fmtDate(linkStatus.linked_at)} />
|
|
)}
|
|
</div>
|
|
<div className="pt-1">
|
|
<button
|
|
onClick={unlink}
|
|
disabled={unlinking}
|
|
className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-50"
|
|
>
|
|
{unlinking ? "Unlinking…" : "Unlink Discord account"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-gray-400">
|
|
Link your Discord account to access private trips from both the web and Discord.
|
|
</p>
|
|
{code ? (
|
|
<div className="space-y-2">
|
|
<div className="bg-gray-800 rounded-lg px-4 py-3 flex items-center gap-3">
|
|
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
|
|
</div>
|
|
<p className="text-xs text-gray-400">
|
|
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
|
|
</p>
|
|
<button
|
|
onClick={generateCode}
|
|
disabled={generating}
|
|
className="text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-50"
|
|
>
|
|
Generate new code
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={generateCode}
|
|
disabled={generating}
|
|
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2 transition-colors"
|
|
>
|
|
{generating ? "Generating…" : "Get link code"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Sign out */}
|
|
<section className="bg-gray-900 border border-gray-800 rounded-xl">
|
|
<div className="px-4 py-3 flex items-center justify-between">
|
|
<p className="text-sm text-gray-400">Sign out of this device</p>
|
|
<button
|
|
onClick={handleSignOut}
|
|
className="text-sm text-red-500 hover:text-red-400 transition-colors"
|
|
>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ label, value, mono = false, truncate = false }: {
|
|
label: string;
|
|
value: string;
|
|
mono?: boolean;
|
|
truncate?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="flex items-start justify-between gap-4">
|
|
<span className="text-xs text-gray-500 shrink-0">{label}</span>
|
|
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|