Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1590874
feat(shared-validators): add dataIncidentDocSchema, extend breakglass…
claude Apr 27, 2026
7f9a3d9
feat(functions): add requireMfaAuth() guard with tests for Phase 7
claude Apr 27, 2026
4cb0e48
feat(functions): add audit-stream.ts service with BigQuery streaming …
claude Apr 27, 2026
a7a6081
feat(functions): add audit-export-batch and auditExportHealthCheck sc…
claude Apr 27, 2026
e2ebfcb
feat(functions): add resolvedToday + avgResponseTimeMinutes to analyt…
claude Apr 27, 2026
b64fc15
feat(admin-desktop): add bare-bones TOTP enrollment page at /totp-enr…
claude Apr 27, 2026
dabc73b
feat(scripts): add seed-break-glass-config.ts script for Phase 7 brea…
claude Apr 27, 2026
9096fd9
test(functions): add edge-case tests for requireMfaAuth (null auth, m…
claude Apr 27, 2026
127581b
docs: update progress and learnings for Phase 7 PRE-7 completion
claude Apr 27, 2026
067c61a
feat(functions): add initiateBreakGlass + deactivateBreakGlass callab…
claude Apr 27, 2026
7a0052a
feat(functions): add sweepExpiredBreakGlassSessions scheduled trigger…
claude Apr 27, 2026
d7341ed
feat(functions): add declareEmergency callable with 5 tests for Phase…
claude Apr 27, 2026
5ca1398
feat(functions): add declareDataIncident + recordIncidentResponseEven…
claude Apr 27, 2026
9ebac11
feat(functions): add setRetentionExempt, approveErasureRequest, toggl…
claude Apr 27, 2026
1a6b761
feat(functions): add upsertProvincialResource + archiveProvincialReso…
claude Apr 27, 2026
ba88787
feat(rules): add Firestore read rules for data_incidents, provincial_…
claude Apr 27, 2026
64b6111
feat(functions,admin-desktop): export all 7.A callables and add admin…
claude Apr 27, 2026
65d9612
fix: prettier formatting and add rule coverage tests for Phase 7 coll…
claude Apr 27, 2026
230507d
fix(callables,rules): address all CodeRabbit/Sourcery review findings…
claude Apr 27, 2026
a9c6bd9
fix(callables): address review findings round 2 — schema alignment, e…
claude Apr 27, 2026
ee7c432
fix(break-glass): add MFA requirement to deactivateBreakGlass for sec…
claude Apr 27, 2026
88afba1
fix(approve-erasure-request): safeParse with HttpsError + atomic tran…
claude Apr 27, 2026
88d24fe
fix(audit-export-health-check): guard against NaN from malformed BigQ…
claude Apr 27, 2026
6815666
fix(provincial-resources): remove as-cast, add archive existence chec…
claude Apr 27, 2026
954df4c
fix(set-retention-exempt): use safeParse + HttpsError invalid-argumen…
claude Apr 27, 2026
0efada6
chore: update pnpm-lock.yaml for @tanstack/react-query dependency
claude Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions apps/admin-desktop/src/pages/TotpEnrollmentPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useState } from 'react'
import { getAuth, multiFactor, TotpMultiFactorGenerator } from 'firebase/auth'
import type { TotpSecret } from 'firebase/auth'

export function TotpEnrollmentPage() {
const auth = getAuth()
const [totpSecret, setTotpSecret] = useState<TotpSecret | null>(null)
const [verificationCode, setVerificationCode] = useState('')
const [enrolled, setEnrolled] = useState(false)
const [error, setError] = useState<string | null>(null)

async function handleGenerate() {
setError(null)
const user = auth.currentUser
if (!user) {
setError('You must be logged in to enroll TOTP.')
return
}
try {
const session = await multiFactor(user).getSession()
const secret = await TotpMultiFactorGenerator.generateSecret(session)
setTotpSecret(secret)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate secret')
}
}

async function handleEnroll() {
setError(null)
const user = auth.currentUser
if (!user || !totpSecret) return
try {
const assertion = TotpMultiFactorGenerator.assertionForEnrollment(
totpSecret,
verificationCode,
)
await multiFactor(user).enroll(assertion, 'Authenticator')
setEnrolled(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Enrollment failed')
}
}

if (enrolled) return <p>TOTP enrolled successfully.</p>

return (
<div>
<h1>Enroll TOTP Authenticator</h1>
{!totpSecret ? (
<button onClick={() => void handleGenerate()}>Generate Secret</button>
) : (
<>
<p>
Secret key: <code>{totpSecret.secretKey}</code>
</p>
<p>
QR URI:{' '}
<code>
{totpSecret.generateQrCodeUrl('Bantayog Alert', auth.currentUser?.email ?? 'admin')}
</code>
</p>
Comment on lines +56 to +61
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

QR URI displayed as text instead of scannable QR code.

Users must manually copy the URI rather than scan a QR code. Consider rendering an actual QR code image using a library like qrcode.react:

Suggested improvement
import { QRCodeSVG } from 'qrcode.react'

// Replace text display with:
<QRCodeSVG 
  value={totpSecret.generateQrCodeUrl('Bantayog Alert', auth.currentUser?.email ?? 'admin')} 
  size={200}
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/pages/TotpEnrollmentPage.tsx` around lines 56 - 61,
The UI currently renders the TOTP QR URI as plain text (see
totpSecret.generateQrCodeUrl and auth.currentUser?.email usage) which forces
users to copy it; replace the text output with a rendered QR image by importing
a QR component (e.g., QRCodeSVG from 'qrcode.react') and pass the generated URI
to its value prop and set a sensible size (e.g., 200) so users can scan
directly; update the JSX in TotpEnrollmentPage.tsx to render the QR component
instead of the <code> block and keep a small fallback/display of the URI for
copy/paste if needed.

<input
placeholder="6-digit code"
value={verificationCode}
onChange={(e) => {
setVerificationCode(e.target.value)
}}
/>
<button onClick={() => void handleEnroll()}>Verify and Enroll</button>
Comment on lines +62 to +69
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding input validation for verification code.

The 6-digit TOTP code is passed directly to Firebase without client-side format validation. Adding a pattern or input type can improve UX:

Proposed improvement
 <input
   placeholder="6-digit code"
+  type="text"
+  inputMode="numeric"
+  pattern="[0-9]{6}"
+  maxLength={6}
   value={verificationCode}
   onChange={(e) => {
-    setVerificationCode(e.target.value)
+    setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))
   }}
 />
+<button 
+  onClick={() => void handleEnroll()}
+  disabled={verificationCode.length !== 6}
+>
+  Verify and Enroll
+</button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<input
placeholder="6-digit code"
value={verificationCode}
onChange={(e) => {
setVerificationCode(e.target.value)
}}
/>
<button onClick={() => void handleEnroll()}>Verify and Enroll</button>
<input
placeholder="6-digit code"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
value={verificationCode}
onChange={(e) => {
setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))
}}
/>
<button
onClick={() => void handleEnroll()}
disabled={verificationCode.length !== 6}
>
Verify and Enroll
</button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/pages/TotpEnrollmentPage.tsx` around lines 62 - 69,
The verificationCode input accepts any text and sends it to handleEnroll without
client-side validation; add validation to TotpEnrollmentPage by restricting the
input to digits and a 6-character length (e.g., add an input pattern/maxlength
and/or input type="text" with onChange sanitizing non-digits in
setVerificationCode) and perform a pre-submit check in handleEnroll to ensure
verificationCode matches /^\d{6}$/ before calling Firebase, showing a
user-friendly error if invalid.

</>
)}
{error && <p role="alert">{error}</p>}
</div>
)
}
2 changes: 2 additions & 0 deletions apps/admin-desktop/src/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { createBrowserRouter } from 'react-router-dom'
import { ProtectedRoute } from '@bantayog/shared-ui'
import { LoginPage } from './pages/LoginPage'
import { TotpEnrollmentPage } from './pages/TotpEnrollmentPage'
import { TriageQueuePage } from './pages/TriageQueuePage'
import { AgencyAssistanceQueuePage } from './pages/AgencyAssistanceQueuePage'
import { AnalyticsDashboardPage } from './pages/AnalyticsDashboardPage'
import { RosterPage } from './pages/RosterPage'

export const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{ path: '/totp-enroll', element: <TotpEnrollmentPage /> },
{
path: '/',
element: (
Expand Down
68 changes: 68 additions & 0 deletions apps/admin-desktop/src/services/callables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,72 @@ export const callables = {
functions,
'bulkAvailabilityOverride',
)(payload).then((r) => r.data),
initiateBreakGlass: (payload: { codeA: string; codeB: string; reason: string }) =>
httpsCallable<typeof payload, { sessionId: string }>(
functions,
'initiateBreakGlass',
)(payload).then((r) => r.data),
deactivateBreakGlass: () =>
httpsCallable<Record<string, never>>(functions, 'deactivateBreakGlass')({}).then((r) => r.data),
declareEmergency: (payload: {
hazardType: string
affectedMunicipalityIds: string[]
message: string
}) =>
httpsCallable<typeof payload, { alertId: string }>(
functions,
'declareEmergency',
)(payload).then((r) => r.data),
declareDataIncident: (payload: {
incidentType: string
severity: string
affectedCollections: string[]
affectedDataClasses: string[]
estimatedAffectedSubjects?: number
summary: string
}) =>
httpsCallable<typeof payload, { incidentId: string }>(
functions,
'declareDataIncident',
)(payload).then((r) => r.data),
recordIncidentResponseEvent: (payload: { incidentId: string; phase: string; notes?: string }) =>
httpsCallable<typeof payload, { eventId: string }>(
functions,
'recordIncidentResponseEvent',
)(payload).then((r) => r.data),
setRetentionExempt: (payload: {
collection: string
documentId: string
exempt: boolean
reason: string
}) => httpsCallable<typeof payload>(functions, 'setRetentionExempt')(payload).then((r) => r.data),
approveErasureRequest: (payload: {
erasureRequestId: string
approved: boolean
reason?: string
}) =>
httpsCallable<typeof payload>(functions, 'approveErasureRequest')(payload).then((r) => r.data),
toggleMutualAidVisibility: (payload: { agencyId: string; visible: boolean }) =>
httpsCallable<typeof payload>(
functions,
'toggleMutualAidVisibility',
)(payload).then((r) => r.data),
Comment on lines +179 to +195
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add explicit void return type for callables with no response data.

Several wrappers omit the response type generic, causing r.data to be typed as unknown. Per the context snippets, setRetentionExempt, approveErasureRequest, and toggleMutualAidVisibility return void from the backend.

♻️ Proposed explicit void typing
   setRetentionExempt: (payload: {
     collection: string
     documentId: string
     exempt: boolean
     reason: string
-  }) => httpsCallable<typeof payload>(functions, 'setRetentionExempt')(payload).then((r) => r.data),
+  }) => httpsCallable<typeof payload, void>(functions, 'setRetentionExempt')(payload).then(() => undefined),
   approveErasureRequest: (payload: {
     erasureRequestId: string
     approved: boolean
     reason?: string
   }) =>
-    httpsCallable<typeof payload>(functions, 'approveErasureRequest')(payload).then((r) => r.data),
+    httpsCallable<typeof payload, void>(functions, 'approveErasureRequest')(payload).then(() => undefined),
   toggleMutualAidVisibility: (payload: { agencyId: string; visible: boolean }) =>
-    httpsCallable<typeof payload>(
+    httpsCallable<typeof payload, void>(
       functions,
       'toggleMutualAidVisibility',
-    )(payload).then((r) => r.data),
+    )(payload).then(() => undefined),

This ensures callers get proper void typing instead of unknown.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setRetentionExempt: (payload: {
collection: string
documentId: string
exempt: boolean
reason: string
}) => httpsCallable<typeof payload>(functions, 'setRetentionExempt')(payload).then((r) => r.data),
approveErasureRequest: (payload: {
erasureRequestId: string
approved: boolean
reason?: string
}) =>
httpsCallable<typeof payload>(functions, 'approveErasureRequest')(payload).then((r) => r.data),
toggleMutualAidVisibility: (payload: { agencyId: string; visible: boolean }) =>
httpsCallable<typeof payload>(
functions,
'toggleMutualAidVisibility',
)(payload).then((r) => r.data),
setRetentionExempt: (payload: {
collection: string
documentId: string
exempt: boolean
reason: string
}) => httpsCallable<typeof payload, void>(functions, 'setRetentionExempt')(payload).then(() => undefined),
approveErasureRequest: (payload: {
erasureRequestId: string
approved: boolean
reason?: string
}) =>
httpsCallable<typeof payload, void>(functions, 'approveErasureRequest')(payload).then(() => undefined),
toggleMutualAidVisibility: (payload: { agencyId: string; visible: boolean }) =>
httpsCallable<typeof payload, void>(
functions,
'toggleMutualAidVisibility',
)(payload).then(() => undefined),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/services/callables.ts` around lines 179 - 195, The
wrappers setRetentionExempt, approveErasureRequest, and
toggleMutualAidVisibility call httpsCallable with only the request generic so
r.data is typed as unknown; fix by specifying the response generic as void (e.g.
httpsCallable<typeof payload, void>(functions, 'setRetentionExempt')) for each
of those functions so the returned r.data is correctly typed as void; update the
generics for setRetentionExempt, approveErasureRequest, and
toggleMutualAidVisibility accordingly.

upsertProvincialResource: (payload: {
id?: string
name: string
type: string
quantity: number
unit: string
location: string
available: boolean
}) =>
httpsCallable<typeof payload, { id: string }>(
functions,
'upsertProvincialResource',
)(payload).then((r) => r.data),
archiveProvincialResource: (payload: { id: string }) =>
httpsCallable<typeof payload>(
functions,
'archiveProvincialResource',
)(payload).then((r) => r.data),
Comment on lines +209 to +213
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Same void-typing issue for archiveProvincialResource.

♻️ Proposed fix
   archiveProvincialResource: (payload: { id: string }) =>
-    httpsCallable<typeof payload>(
+    httpsCallable<typeof payload, void>(
       functions,
       'archiveProvincialResource',
-    )(payload).then((r) => r.data),
+    )(payload).then(() => undefined),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
archiveProvincialResource: (payload: { id: string }) =>
httpsCallable<typeof payload>(
functions,
'archiveProvincialResource',
)(payload).then((r) => r.data),
archiveProvincialResource: (payload: { id: string }) =>
httpsCallable<typeof payload, void>(
functions,
'archiveProvincialResource',
)(payload).then(() => undefined),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/services/callables.ts` around lines 209 - 213, The
callable archiveProvincialResource is using typeof payload as the httpsCallable
generic which wrongly types the response as the payload; update the generics to
the correct request and response shapes (e.g., httpsCallable<{ id: string },
void>) so the request type is { id: string } and the response is properly void
(or the actual response type if non-void) for archiveProvincialResource; adjust
the .then((r) => r.data) usage if the response type changes.

}
10 changes: 10 additions & 0 deletions docs/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@
- Capacitor void-return callbacks need braces: `return () => { clearInterval(id) }`.
- When refactoring from `refCount` to `Set<subscribers>`, remove ALL stale `refCount` references.

## Phase 7 — Provincial Superadmin

- `@google-cloud/bigquery` `.table.query()` doesn't exist; use `bq.query()` directly for SQL queries.
- BigQuery query results are untyped; extract into typed helpers with `as unknown as RowType[]` to satisfy strict ESLint rules (`no-unsafe-member-access`, `no-unsafe-argument`).
- `@typescript-eslint/no-unnecessary-condition` flags `?.` on non-optional fields in function parameter types — use `.` when the type declares the field as required.
- Firestore path template literals (`db.doc(\`...\`)`) trigger `no-restricted-syntax`lint; use chained`.collection().doc()` instead.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the malformed code spans on this line.

markdownlint is already flagging this entry (MD038) because the inline code runs into surrounding text. Add the missing spaces so docs lint stays green.

📝 Proposed fix
-- Firestore path template literals (`db.doc(\`...\`)`) trigger `no-restricted-syntax`lint; use chained`.collection().doc()` instead.
+- Firestore path template literals (`db.doc(\`...\`)`) trigger `no-restricted-syntax` lint; use chained `.collection().doc()` instead.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Firestore path template literals (`db.doc(\`...\`)`) trigger `no-restricted-syntax`lint; use chained`.collection().doc()` instead.
- Firestore path template literals (`db.doc(\`...\`)`) trigger `no-restricted-syntax` lint; use chained `.collection().doc()` instead.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 104-104: Spaces inside code span elements

(MD038, no-space-in-code)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/learnings.md` at line 104, The inline code span on the line containing
Firestore examples is malformed because the backticked snippet `db.doc(\`...\`)`
runs into surrounding text; fix it by adding spaces before and after each inline
code block so markdownlint MD038 is satisfied—for example ensure there's a space
before and after `db.doc(\`...\`)` and `.collection().doc()` in that sentence
and keep the backticks intact so the code spans render correctly.

- `@typescript-eslint/no-misused-promises` flags async onClick handlers; wrap with `() => void asyncFn()`.
- `bcryptjs` preferred over `bcrypt` in this repo — pure JS, no native compilation.
- `@google-cloud/logging` must be added as explicit dependency when using Cloud Logging API in triggers.

## Misc

- `navigator.clipboard` in happy-dom often needs to be defined as an own property before spying.
Expand Down
26 changes: 25 additions & 1 deletion docs/progress.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
# Progress

## Current — Phase 6 Responder App (branch: `phase6/responder-app`)
## Current — Phase 7 Provincial Superadmin + NDRRMC + Break-Glass

### PRE-7 — Audit & Auth Foundation (branch: `feature/phase7-pre`)

| Task | Status | Notes |
| --------------------------------------- | ------- | ------------------------------------------------------------------------------------------------- |
| 1. Schema additions (shared-validators) | ✅ DONE | `dataIncidentDocSchema`, extended `breakglassEventDocSchema` + `agencyDocSchema`. 229 tests pass. |
| 2. `requireMfaAuth()` + tests | ✅ DONE | 6 test cases including edge cases (null auth, missing firebase, non-string factor). 14/14 pass. |
| 3. `audit-stream.ts` service | ✅ DONE | Fire-and-forget BigQuery streaming. `@google-cloud/bigquery@^7.9.2` added. |
| 4. Audit export batch + health check | ✅ DONE | 5min batch, 10min health check with FCM alert. `@google-cloud/logging` added. |
| 5. Analytics snapshot extension | ✅ DONE | `resolvedToday` + `avgResponseTimeMinutes` on province summary. 7/7 tests. |
| 6. Bare-bones TOTP enrollment page | ✅ DONE | `/totp-enroll` route, unprotected. Firebase v12 TOTP MFA. |
| 7. Seed break-glass config script | ✅ DONE | `bcryptjs`, idempotent, `system_config/break_glass_config`. |

**Staging gate:** Pending — needs 24h soak before 7.A can deploy.

### 7.A — Security Callables (branch: `feature/phase7-a`) — IN PROGRESS

### 7.B — Superadmin UI (branch: `feature/phase7-b`) — BLOCKED by 7.A

### 7.C — Drill & Verification — BLOCKED by 7.B

---

## Phase 6 — Responder App (branch: `phase6/responder-app`) — COMPLETE

| Task | Status | Notes |
| ------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
Expand Down
4 changes: 4 additions & 0 deletions functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
},
"dependencies": {
"@bantayog/shared-data": "workspace:*",
"@google-cloud/bigquery": "^7.9.2",
"@google-cloud/logging": "^7.0.0",
"@bantayog/shared-sms-parser": "workspace:*",
"@bantayog/shared-types": "workspace:*",
"@bantayog/shared-validators": "workspace:*",
Expand All @@ -41,10 +43,12 @@
"geojson": "^0.5.0",
"ngeohash": "^0.6.3",
"sharp": "^0.34.5",
"bcryptjs": "^2.4.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@firebase/rules-unit-testing": "^5.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/ngeohash": "^0.6.8",
"@types/node": "^20.12.0",
"firebase": "^12.0.0",
Expand Down
Loading
Loading