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) }
196 lines
4.7 KiB
TypeScript
196 lines
4.7 KiB
TypeScript
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
|
|
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
|
export type UserRole = "admin" | "operator" | "viewer";
|
|
|
|
export interface UserRecord {
|
|
uid: string;
|
|
email: string | null;
|
|
display_name: string | null;
|
|
role: UserRole;
|
|
owned_node_ids: string[];
|
|
disabled: boolean;
|
|
creation_time: string | null;
|
|
last_sign_in: string | null;
|
|
discord_linked: boolean;
|
|
discord_username: string | null;
|
|
discord_user_id: string | null;
|
|
// only present on GET /admin/users/{uid}
|
|
sessions?: UserSession[];
|
|
// only present on POST /admin/users response
|
|
invite_link?: string | null;
|
|
}
|
|
|
|
export interface UserSession {
|
|
session_id: string;
|
|
uid: string;
|
|
email: string;
|
|
timestamp: string;
|
|
ip: string | null;
|
|
user_agent: string | null;
|
|
}
|
|
|
|
export interface AuditEntry {
|
|
log_id: string;
|
|
action: string;
|
|
actor_uid: string;
|
|
actor_email: string;
|
|
target_uid: string | null;
|
|
target_email: string | null;
|
|
details: Record<string, unknown>;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface NodeRecord {
|
|
node_id: string;
|
|
name: string;
|
|
lat: number;
|
|
lon: number;
|
|
status: NodeStatus;
|
|
configured: boolean;
|
|
last_seen: string | null;
|
|
assigned_system_id: string | null;
|
|
approval_status: ApprovalStatus | null;
|
|
hardware_preset?: string;
|
|
ppm_override?: number | null;
|
|
}
|
|
|
|
export interface VocabularyPendingTerm {
|
|
term: string;
|
|
source: "induction" | "correction";
|
|
added_at: string;
|
|
source_call_ids?: string[];
|
|
}
|
|
|
|
export interface SystemRecord {
|
|
system_id: string;
|
|
name: string;
|
|
type: string; // P25 | DMR | NBFM
|
|
config: Record<string, unknown>;
|
|
vocabulary?: string[];
|
|
vocabulary_pending?: VocabularyPendingTerm[];
|
|
vocabulary_bootstrapped?: boolean;
|
|
ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...}
|
|
preferred_token_id?: string | null;
|
|
}
|
|
|
|
export interface TranscriptSegment {
|
|
start: number;
|
|
end: number;
|
|
text: string;
|
|
}
|
|
|
|
export interface CallRecord {
|
|
call_id: string;
|
|
node_id: string;
|
|
system_id: string | null;
|
|
talkgroup_id: number | null;
|
|
talkgroup_name: string | null;
|
|
freq: number | null;
|
|
started_at: string;
|
|
ended_at: string | null;
|
|
audio_url: string | null;
|
|
transcript: string | null;
|
|
transcript_corrected: string | null;
|
|
segments: TranscriptSegment[] | null;
|
|
/** New: one entry per scene detected in the recording. */
|
|
incident_ids: string[];
|
|
/** Legacy field — present on calls recorded before the multi-scene migration. */
|
|
incident_id?: string | null;
|
|
location: string | null;
|
|
tags: string[];
|
|
status: "active" | "ended";
|
|
// Correlation debug — written by the correlator, present after a call is linked
|
|
corr_path?: string | null;
|
|
corr_score?: number | null;
|
|
corr_distance_km?: number | null;
|
|
corr_incident_idle_min?: number | null;
|
|
corr_shared_units?: number | null;
|
|
corr_candidates?: number | null;
|
|
}
|
|
|
|
export interface IncidentRecord {
|
|
incident_id: string;
|
|
title: string | null;
|
|
type: string | null;
|
|
status: "active" | "resolved";
|
|
location: string | null;
|
|
location_coords: { lat: number; lng: number } | null;
|
|
call_ids: string[];
|
|
system_ids: string[];
|
|
talkgroup_ids: string[];
|
|
units: string[];
|
|
vehicles: string[];
|
|
severity: string | null;
|
|
started_at: string;
|
|
updated_at: string;
|
|
summary: string | null;
|
|
tags: string[];
|
|
}
|
|
|
|
export interface AlertRule {
|
|
rule_id: string;
|
|
name: string;
|
|
keywords: string[];
|
|
talkgroup_ids: number[];
|
|
enabled: boolean;
|
|
discord_webhook: string | null;
|
|
created_at?: string;
|
|
}
|
|
|
|
export interface TripEvent {
|
|
event_id: string;
|
|
trip_id: string;
|
|
title: string;
|
|
date: string;
|
|
start_time: string | null;
|
|
end_time: string | null;
|
|
location: string;
|
|
location_inherited: boolean;
|
|
maps_link: string | null;
|
|
place_id: string | null;
|
|
notes: string | null;
|
|
tags: string[];
|
|
attendees: Record<string, string>;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface PlaceResult {
|
|
name: string;
|
|
address: string;
|
|
place_id: string;
|
|
lat: number;
|
|
lng: number;
|
|
maps_link: string;
|
|
rating?: number;
|
|
}
|
|
|
|
export interface TripRecord {
|
|
trip_id: string;
|
|
name: string;
|
|
location: string;
|
|
maps_link: string | null;
|
|
start_date: string;
|
|
end_date: string;
|
|
attendees: Record<string, string>;
|
|
available_tags: string[];
|
|
overlap_tags: string[];
|
|
visibility: "public" | "private";
|
|
invited_discord_ids: string[];
|
|
created_at: string;
|
|
events?: TripEvent[];
|
|
}
|
|
|
|
export interface AlertEvent {
|
|
alert_id: string;
|
|
rule_id: string;
|
|
rule_name: string;
|
|
call_id: string;
|
|
node_id: string;
|
|
talkgroup_id: number | null;
|
|
talkgroup_name: string | null;
|
|
matched_keywords: string[];
|
|
transcript_snippet: string | null;
|
|
triggered_at: string;
|
|
acknowledged: boolean;
|
|
}
|