feat: add role-based user management, audit log, and session tracking
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) }
This commit is contained in:
@@ -31,7 +31,7 @@ function Initials({ name }: { name: string }) {
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, isAdmin, signOut } = useAuth();
|
||||
const { user, isAdmin, role, signOut } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [linkStatus, setLinkStatus] = useState<LinkStatus | null>(null);
|
||||
@@ -94,9 +94,13 @@ export default function ProfilePage() {
|
||||
{user.displayName && user.email && (
|
||||
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<span className="inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full bg-indigo-900 text-indigo-300">
|
||||
Admin
|
||||
{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>
|
||||
@@ -109,7 +113,7 @@ export default function ProfilePage() {
|
||||
<div className="space-y-2">
|
||||
<Row label="Email" value={user.email ?? "—"} />
|
||||
<Row label="UID" value={user.uid} mono truncate />
|
||||
<Row label="Role" value={isAdmin ? "Admin" : "Member"} />
|
||||
<Row label="Role" value={role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} />
|
||||
{user.metadata.creationTime && (
|
||||
<Row label="Joined" value={fmtDate(user.metadata.creationTime)} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user