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) }
74 lines
2.6 KiB
TypeScript
74 lines
2.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useNodes } from "@/lib/useNodes";
|
|
import { useSystems } from "@/lib/useSystems";
|
|
import { NodeCard } from "@/components/NodeCard";
|
|
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
|
import { useAuth } from "@/components/AuthProvider";
|
|
import type { NodeRecord } from "@/lib/types";
|
|
|
|
export default function NodesPage() {
|
|
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
|
const router = useRouter();
|
|
const { nodes, loading } = useNodes();
|
|
const { systems } = useSystems();
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
|
}, [authLoading, isAdmin, isOperator, router]);
|
|
|
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
|
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
|
|
|
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
|
const pending = nodes.filter((n) => !n.configured);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h1 className="text-xl font-bold text-white font-mono">Nodes</h1>
|
|
|
|
{pending.length > 0 && (
|
|
<div className="space-y-2">
|
|
<h2 className="text-sm font-semibold text-indigo-400 uppercase tracking-wider">
|
|
Needs Configuration ({pending.length})
|
|
</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{pending.map((n) => (
|
|
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
|
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">
|
|
All Nodes ({nodes.length})
|
|
</h2>
|
|
{loading ? (
|
|
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
|
) : nodes.length === 0 ? (
|
|
<p className="text-gray-600 text-sm font-mono">No nodes registered yet. Boot a Pi to get started.</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{nodes.map((n) => (
|
|
<NodeCard key={n.node_id} node={n} system={systemMap[n.assigned_system_id ?? ""]} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{configNode && (
|
|
<NodeConfigModal
|
|
node={configNode}
|
|
systems={systems}
|
|
onClose={() => setConfigNode(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|