-
Notifications
You must be signed in to change notification settings - Fork 0
feat(admin): Phase 3b — Admin Triage Dispatch #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 49 commits
8d43f85
0d9e1a8
a5320de
dc98e75
812f46f
c8c1334
3d855f7
dc4fb0e
f967af1
a2f7f82
50ff0e7
fc18bb4
aed5074
33b5fa4
cb52197
1e84a7a
d83005b
cf37f27
45e0774
9633186
1f56e76
bb3fa38
246e999
ef40a60
d09bfdf
8e809e4
8247779
18ede24
34b4009
d4a3a95
1da17af
a8a6b80
dc466f7
65dbb60
252ded8
1bdc80f
b0bed63
57d8acf
f96bbce
068f04d
2be0eff
30d7ab7
53ec5a4
4879853
27f454f
c11cffe
4d3406d
0b99693
971607e
31bfe92
5d991c4
d6d2d48
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,11 @@ | ||
| import styles from './App.module.css' | ||
| import { RouterProvider } from 'react-router-dom' | ||
| import { AuthProvider } from './app/auth-provider' | ||
| import { router } from './routes' | ||
|
|
||
| export function App() { | ||
| export default function App() { | ||
| return ( | ||
| <main className={styles.container}> | ||
| <h1 className={styles.heading}>Bantayog Alert — Admin</h1> | ||
| <p className={styles.subheading}>Phase 0 scaffolding. Admin dashboard arrives in Phase 3.</p> | ||
| </main> | ||
| <AuthProvider> | ||
| <RouterProvider router={router} /> | ||
| </AuthProvider> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' | ||
| import { onAuthStateChanged, signOut as fbSignOut, type User } from 'firebase/auth' | ||
| import { auth } from './firebase' | ||
|
|
||
| export interface AdminClaims { | ||
| role?: string | ||
| municipalityId?: string | ||
| active?: boolean | ||
| } | ||
|
|
||
| interface AuthState { | ||
| user: User | null | ||
| claims: AdminClaims | null | ||
| loading: boolean | ||
| signOut: () => Promise<void> | ||
| refreshClaims: () => Promise<void> | ||
| } | ||
|
|
||
| const Ctx = createContext<AuthState | null>(null) | ||
|
|
||
| export function AuthProvider({ children }: { children: ReactNode }) { | ||
| const [user, setUser] = useState<User | null>(null) | ||
| const [claims, setClaims] = useState<AdminClaims | null>(null) | ||
| const [loading, setLoading] = useState(true) | ||
|
|
||
| const refreshClaims = async () => { | ||
| if (!auth.currentUser) { | ||
| setClaims(null) | ||
| return | ||
| } | ||
| const tok = await auth.currentUser.getIdTokenResult(true) | ||
| setClaims({ | ||
| role: tok.claims.role as string | undefined, | ||
| municipalityId: tok.claims.municipalityId as string | undefined, | ||
| active: tok.claims.active === true, | ||
| } as AdminClaims) | ||
| } | ||
|
|
||
| useEffect(() => { | ||
| const unsubscribe = onAuthStateChanged(auth, (u) => { | ||
| setUser(u) | ||
| if (u) { | ||
| void u.getIdTokenResult().then((tok) => { | ||
| setClaims({ | ||
| role: tok.claims.role as string | undefined, | ||
| municipalityId: tok.claims.municipalityId as string | undefined, | ||
| active: tok.claims.active === true, | ||
| } as AdminClaims) | ||
| setLoading(false) | ||
| }) | ||
| } else { | ||
| setClaims(null) | ||
| setLoading(false) | ||
| } | ||
| }) | ||
| return unsubscribe | ||
| }, []) | ||
|
|
||
| return ( | ||
| <Ctx.Provider value={{ user, claims, loading, signOut: () => fbSignOut(auth), refreshClaims }}> | ||
| {children} | ||
| </Ctx.Provider> | ||
| ) | ||
| } | ||
|
|
||
| export function useAuth() { | ||
| const v = useContext(Ctx) | ||
| if (!v) throw new Error('useAuth must be used inside AuthProvider') | ||
| return v | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { initializeApp } from 'firebase/app' | ||
| import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore' | ||
| import { getAuth, connectAuthEmulator } from 'firebase/auth' | ||
| import { getFunctions, connectFunctionsEmulator } from 'firebase/functions' | ||
| import { initializeAppCheck, ReCaptchaV3Provider } from 'firebase/app-check' | ||
|
|
||
| const useEmulator = import.meta.env.VITE_USE_EMULATOR === 'true' | ||
|
|
||
| const firebaseConfig = { | ||
| apiKey: import.meta.env.VITE_FIREBASE_API_KEY, | ||
| authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, | ||
| projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, | ||
| storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, | ||
| messagingSenderId: import.meta.env.VITE_FIREBASE_MSG_SENDER_ID, | ||
| appId: import.meta.env.VITE_FIREBASE_APP_ID, | ||
| } | ||
|
|
||
| export const firebaseApp = initializeApp(firebaseConfig) | ||
|
|
||
| const recaptchaKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY | ||
|
|
||
| if (!useEmulator) { | ||
| if (recaptchaKey) { | ||
| initializeAppCheck(firebaseApp, { | ||
| provider: new ReCaptchaV3Provider(recaptchaKey as string), | ||
| isTokenAutoRefreshEnabled: true, | ||
| }) | ||
| } else { | ||
| console.warn( | ||
| '[firebase] VITE_RECAPTCHA_SITE_KEY not set — App Check disabled. DO NOT USE IN PRODUCTION.', | ||
| ) | ||
|
Comment on lines
+28
to
+31
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail fast in production when App Check key is missing. In non-emulator mode, missing 🔧 Proposed guard } else {
- console.warn(
- '[firebase] VITE_RECAPTCHA_SITE_KEY not set — App Check disabled. DO NOT USE IN PRODUCTION.',
- )
+ if (import.meta.env.PROD) {
+ throw new Error(
+ '[firebase] Missing VITE_RECAPTCHA_SITE_KEY in production; refusing to start without App Check.',
+ )
+ }
+ console.warn('[firebase] VITE_RECAPTCHA_SITE_KEY not set — App Check disabled in non-prod.')
}🤖 Prompt for AI Agents |
||
| } | ||
| } else if (typeof window !== 'undefined') { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access -- Firebase App Check debug token is a browser global | ||
| ;(self as any).FIREBASE_APPCHECK_DEBUG_TOKEN = import.meta.env.VITE_APPCHECK_DEBUG_TOKEN ?? true | ||
| } | ||
|
|
||
| export const db = getFirestore(firebaseApp) | ||
| export const auth = getAuth(firebaseApp) | ||
| export const functions = getFunctions(firebaseApp, 'asia-southeast1') | ||
|
|
||
| if (useEmulator) { | ||
| connectFirestoreEmulator(db, 'localhost', 8080) | ||
| connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }) | ||
| connectFunctionsEmulator(functions, 'localhost', 5001) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { type ReactNode } from 'react' | ||
| import { Navigate, useLocation } from 'react-router-dom' | ||
| import { useAuth } from './auth-provider' | ||
|
|
||
| export function ProtectedRoute({ children }: { children: ReactNode }) { | ||
| const { user, claims, loading } = useAuth() | ||
| const location = useLocation() | ||
|
|
||
| if (loading) return <div>Loading…</div> | ||
| if (!user) return <Navigate to="/login" state={{ from: location }} replace /> | ||
|
|
||
| if (claims?.role !== 'municipal_admin' && claims?.role !== 'provincial_superadmin') { | ||
| return ( | ||
| <div role="alert"> | ||
| You don't have admin access on this account. Contact your municipality's | ||
| superadmin. | ||
| </div> | ||
| ) | ||
| } | ||
| if (claims.active !== true) { | ||
| return <div role="alert">Your account is not active. Please contact your superadmin.</div> | ||
| } | ||
| if (claims.role === 'municipal_admin' && !claims.municipalityId) { | ||
| return ( | ||
| <div role="alert"> | ||
| Your admin account is missing a municipality assignment. Contact superadmin. | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| return <>{children}</> | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,53 @@ | ||||||||||||||||||||||||||||||||||||||
| import { useEffect, useState } from 'react' | ||||||||||||||||||||||||||||||||||||||
| import { collection, onSnapshot, query, where } from 'firebase/firestore' | ||||||||||||||||||||||||||||||||||||||
| import { db } from '../app/firebase' | ||||||||||||||||||||||||||||||||||||||
| import { getDatabase, ref, onValue } from 'firebase/database' | ||||||||||||||||||||||||||||||||||||||
| import { firebaseApp } from '../app/firebase' | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export interface EligibleResponder { | ||||||||||||||||||||||||||||||||||||||
| uid: string | ||||||||||||||||||||||||||||||||||||||
| displayName: string | ||||||||||||||||||||||||||||||||||||||
| agencyId: string | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export function useEligibleResponders(municipalityId: string | undefined) { | ||||||||||||||||||||||||||||||||||||||
| const [responders, setResponders] = useState<Record<string, EligibleResponder>>({}) | ||||||||||||||||||||||||||||||||||||||
| const [shift, setShift] = useState<Record<string, { isOnShift: boolean }>>({}) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||
| if (!municipalityId) return | ||||||||||||||||||||||||||||||||||||||
| const q = query( | ||||||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||
| collection(db, 'responders'), | ||||||||||||||||||||||||||||||||||||||
| where('municipalityId', '==', municipalityId), | ||||||||||||||||||||||||||||||||||||||
| where('isActive', '==', true), | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| return onSnapshot(q, (snap) => { | ||||||||||||||||||||||||||||||||||||||
| const out: Record<string, EligibleResponder> = {} | ||||||||||||||||||||||||||||||||||||||
| snap.docs.forEach((d) => { | ||||||||||||||||||||||||||||||||||||||
| const data = d.data() | ||||||||||||||||||||||||||||||||||||||
| out[d.id] = { | ||||||||||||||||||||||||||||||||||||||
| uid: d.id, | ||||||||||||||||||||||||||||||||||||||
| displayName: String(data.displayName ?? d.id), | ||||||||||||||||||||||||||||||||||||||
| agencyId: String(data.agencyId ?? 'unknown'), | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+31
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Align fallback normalization with backend eligibility contract. Line [33]-Line [34] default to Proposed fix out[d.id] = {
uid: d.id,
- displayName: String(data.displayName ?? d.id),
- agencyId: String(data.agencyId ?? 'unknown'),
+ displayName: String(data.displayName ?? ''),
+ agencyId: String(data.agencyId ?? ''),
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||
| setResponders(out) | ||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||
| }, [municipalityId]) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||
| if (!municipalityId) return | ||||||||||||||||||||||||||||||||||||||
| const rtdb = getDatabase(firebaseApp) | ||||||||||||||||||||||||||||||||||||||
| const node = ref(rtdb, `/responder_index/${municipalityId}`) | ||||||||||||||||||||||||||||||||||||||
| const unsub = onValue(node, (s) => { | ||||||||||||||||||||||||||||||||||||||
| const snapVal = s.val() | ||||||||||||||||||||||||||||||||||||||
| setShift(snapVal !== null ? (snapVal as Record<string, { isOnShift: boolean }>) : {}) | ||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+48
to
+51
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate RTDB snapshot shape before committing to state. Line [50] trusts untyped RTDB payload via direct cast. This skips boundary validation and can store malformed entries. Proposed fix const unsub = onValue(node, (s) => {
const snapVal = s.val()
- setShift(snapVal !== null ? (snapVal as Record<string, { isOnShift: boolean }>) : {})
+ if (!snapVal || typeof snapVal !== 'object') {
+ setShift({})
+ return
+ }
+ const normalized: Record<string, { isOnShift: boolean }> = {}
+ for (const [uid, value] of Object.entries(snapVal as Record<string, unknown>)) {
+ if (value && typeof value === 'object' && (value as { isOnShift?: unknown }).isOnShift === true) {
+ normalized[uid] = { isOnShift: true }
+ }
+ }
+ setShift(normalized)
})As per coding guidelines: "Use defensive programming: validate external input at boundaries and never swallow errors with empty catch blocks." 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| return unsub | ||||||||||||||||||||||||||||||||||||||
| }, [municipalityId]) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const eligible = Object.values(responders) | ||||||||||||||||||||||||||||||||||||||
| .filter((r) => shift[r.uid]?.isOnShift === true) | ||||||||||||||||||||||||||||||||||||||
| .sort((a, b) => a.displayName.localeCompare(b.displayName)) | ||||||||||||||||||||||||||||||||||||||
| return eligible | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { useEffect, useState } from 'react' | ||
| import { collection, onSnapshot, query, where, orderBy, limit, Timestamp } from 'firebase/firestore' | ||
| import { db } from '../app/firebase' | ||
|
|
||
| export interface MuniReportRow { | ||
| reportId: string | ||
| status: string | ||
| severityDerived: string | ||
| createdAt: Timestamp | ||
| municipalityLabel: string | ||
| } | ||
|
|
||
| export function useMuniReports(municipalityId: string | undefined) { | ||
| const [rows, setRows] = useState<MuniReportRow[]>([]) | ||
| const [loading, setLoading] = useState(true) | ||
| const [error, setError] = useState<string | null>(null) | ||
|
|
||
| useEffect(() => { | ||
| if (!municipalityId) { | ||
| setRows([]) | ||
| setLoading(false) | ||
| return | ||
| } | ||
|
Comment on lines
+19
to
+23
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reset The hook sets Proposed fix useEffect(() => {
if (!municipalityId) {
setRows([])
+ setError(null)
setLoading(false)
return
}
+ setError(null)
setLoading(true)
@@
(snap) => {
setRows(
@@
)
+ setError(null)
setLoading(false)
},Also applies to: 34-50 🤖 Prompt for AI Agents |
||
| setLoading(true) | ||
| const q = query( | ||
| collection(db, 'reports'), | ||
| where('municipalityId', '==', municipalityId), | ||
| where('status', 'in', ['new', 'awaiting_verify', 'verified', 'assigned']), | ||
| orderBy('createdAt', 'desc'), | ||
| limit(100), | ||
| ) | ||
| const unsub = onSnapshot( | ||
| q, | ||
| (snap) => { | ||
| setRows( | ||
| snap.docs.map((d) => { | ||
| const data = d.data() | ||
| return { | ||
| reportId: d.id, | ||
| status: String(data.status), | ||
| severityDerived: String(data.severityDerived ?? 'medium'), | ||
| createdAt: data.createdAt as Timestamp, | ||
| municipalityLabel: String(data.municipalityLabel ?? ''), | ||
| } | ||
| }), | ||
| ) | ||
| setLoading(false) | ||
| }, | ||
| (err) => { | ||
| setError(err.message) | ||
| setLoading(false) | ||
| }, | ||
| ) | ||
| return unsub | ||
| }, [municipalityId]) | ||
|
|
||
| return { rows, loading, error } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { useEffect, useState } from 'react' | ||
| import { doc, onSnapshot } from 'firebase/firestore' | ||
| import { db } from '../app/firebase' | ||
| import type { Timestamp } from 'firebase/firestore' | ||
|
|
||
| export interface ReportDetail { | ||
| reportId: string | ||
| status: string | ||
| municipalityLabel: string | ||
| severityDerived: string | ||
| createdAt: Timestamp | ||
| verifiedBy?: string | ||
| verifiedAt?: Timestamp | ||
| currentDispatchId?: string | ||
| } | ||
| export interface ReportOps { | ||
| verifyQueuePriority: number | ||
| assignedMunicipalityAdmins: string[] | ||
| } | ||
|
|
||
| export function useReportDetail(reportId: string | undefined) { | ||
| const [report, setReport] = useState<ReportDetail | null>(null) | ||
| const [ops, setOps] = useState<ReportOps | null>(null) | ||
| const [error, setError] = useState<string | null>(null) | ||
|
|
||
| useEffect(() => { | ||
| if (!reportId) { | ||
| setReport(null) | ||
| setOps(null) | ||
| return | ||
|
Comment on lines
+27
to
+30
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reset
Proposed fix useEffect(() => {
if (!reportId) {
setReport(null)
setOps(null)
+ setError(null)
return
}
+ setError(null)
const u1 = onSnapshot(
doc(db, 'reports', reportId),
(s) => {
@@
)
+ setError(null)
},
@@
(s) => {
setOps(s.exists() ? (s.data() as ReportOps) : null)
+ setError(null)
},Also applies to: 41-53 🤖 Prompt for AI Agents |
||
| } | ||
| const u1 = onSnapshot( | ||
| doc(db, 'reports', reportId), | ||
| (s) => { | ||
| setReport( | ||
| s.exists() | ||
| ? ({ reportId: s.id, ...(s.data() as Partial<ReportDetail>) } as ReportDetail) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent persisted payload from overriding canonical Current spread order allows Firestore data to overwrite Proposed fix- ? ({ reportId: s.id, ...(s.data() as Partial<ReportDetail>) } as ReportDetail)
+ ? ({ ...(s.data() as Partial<ReportDetail>), reportId: s.id } as ReportDetail)🤖 Prompt for AI Agents |
||
| : null, | ||
| ) | ||
| }, | ||
| (err) => { | ||
| setError(`reports: ${err.message}`) | ||
| }, | ||
| ) | ||
| const u2 = onSnapshot( | ||
| doc(db, 'report_ops', reportId), | ||
| (s) => { | ||
| setOps(s.exists() ? (s.data() as ReportOps) : null) | ||
| }, | ||
| (err) => { | ||
| setError((prev) => | ||
| prev ? `${prev}; report_ops: ${err.message}` : `report_ops: ${err.message}`, | ||
| ) | ||
| }, | ||
| ) | ||
| return () => { | ||
| u1() | ||
| u2() | ||
| } | ||
| }, [reportId]) | ||
|
|
||
| return { report, ops, error } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,7 @@ | ||
| import { StrictMode } from 'react' | ||
| import { createRoot } from 'react-dom/client' | ||
| import { App } from './App.js' | ||
| import App from './App.js' | ||
|
|
||
| const rootEl = document.getElementById('root') | ||
| if (!rootEl) throw new Error('#root element not found') | ||
|
|
||
| createRoot(rootEl).render( | ||
| <StrictMode> | ||
| <App /> | ||
| </StrictMode>, | ||
| ) | ||
| createRoot(rootEl).render(<App />) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle token-fetch failures and stale async resolution in auth state listener.
If
getIdTokenResult()rejects,loadingmay never flip tofalse. Also, async completion can race after auth state changes and set stale claims.Proposed fix
useEffect(() => { const unsubscribe = onAuthStateChanged(auth, (u) => { setUser(u) if (u) { - void u.getIdTokenResult().then((tok) => { - setClaims({ - role: tok.claims.role as string | undefined, - municipalityId: tok.claims.municipalityId as string | undefined, - active: tok.claims.active === true, - } as AdminClaims) - setLoading(false) - }) + void u + .getIdTokenResult() + .then((tok) => { + if (auth.currentUser?.uid !== u.uid) return + setClaims({ + role: tok.claims.role as string | undefined, + municipalityId: tok.claims.municipalityId as string | undefined, + active: tok.claims.active === true, + } as AdminClaims) + }) + .catch(() => { + setClaims(null) + }) + .finally(() => { + if (auth.currentUser?.uid === u.uid) setLoading(false) + }) } else { setClaims(null) setLoading(false) } })📝 Committable suggestion
🤖 Prompt for AI Agents