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) }
82 lines
2.3 KiB
TypeScript
82 lines
2.3 KiB
TypeScript
"use client";
|
|
|
|
import { createContext, useContext, useEffect, useState } from "react";
|
|
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
|
|
import { auth } from "@/lib/firebase";
|
|
import type { UserRole } from "@/lib/types";
|
|
|
|
interface AuthContextType {
|
|
user: User | null;
|
|
loading: boolean;
|
|
role: UserRole | null;
|
|
isAdmin: boolean;
|
|
isOperator: boolean;
|
|
ownedNodeIds: string[];
|
|
signOut: () => Promise<void>;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextType>({
|
|
user: null,
|
|
loading: true,
|
|
role: null,
|
|
isAdmin: false,
|
|
isOperator: false,
|
|
ownedNodeIds: [],
|
|
signOut: async () => {},
|
|
});
|
|
|
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [role, setRole] = useState<UserRole | null>(null);
|
|
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
|
|
|
|
useEffect(() => {
|
|
return onAuthStateChanged(auth, async (u) => {
|
|
setUser(u);
|
|
setLoading(false);
|
|
|
|
if (u) {
|
|
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
|
const result = await u.getIdTokenResult(true);
|
|
const claims = result.claims;
|
|
|
|
// Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean
|
|
let effectiveRole: UserRole = "viewer";
|
|
if (claims.role === "admin" || claims.admin) {
|
|
effectiveRole = "admin";
|
|
} else if (claims.role === "operator") {
|
|
effectiveRole = "operator";
|
|
} else if (claims.role === "viewer") {
|
|
effectiveRole = "viewer";
|
|
}
|
|
|
|
setRole(effectiveRole);
|
|
setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []);
|
|
} else {
|
|
document.cookie = "drb_session=; path=/; max-age=0";
|
|
setRole(null);
|
|
setOwnedNodeIds([]);
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
async function signOut() {
|
|
await firebaseSignOut(auth);
|
|
document.cookie = "drb_session=; path=/; max-age=0";
|
|
}
|
|
|
|
const isAdmin = role === "admin";
|
|
const isOperator = role === "operator";
|
|
|
|
return (
|
|
<AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useAuth() {
|
|
return useContext(AuthContext);
|
|
}
|