Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3b5f3a8
feat(erasure): requestDataErasure callable with sentinel idempotency
Exc1D Apr 29, 2026
4c8bf9b
fix(erasure): approve deny path — Auth re-enable, sentinel delete, pe…
Exc1D Apr 29, 2026
2fe2474
feat(erasure): setErasureLegalHold callable — superadmin pause/resume…
Exc1D Apr 29, 2026
b7b4e5c
feat(erasure): erasureSweep — sequential claim, all-or-nothing anonym…
Exc1D Apr 29, 2026
f381ee5
feat(erasure): retentionSweep — 1-week anonymize, 1-month hard-delete…
Exc1D Apr 29, 2026
f3cdcb0
feat(erasure): Firestore rules — erasure_requests create/read, erasur…
Exc1D Apr 29, 2026
77b53be
feat(erasure): Storage rules — citizen read-own report media, deny un…
Exc1D Apr 29, 2026
da7dd19
feat(erasure): export requestDataErasure, setErasureLegalHold, erasur…
Exc1D Apr 29, 2026
a150d1a
feat(erasure): citizen PWA erasure service — callable wrapper + signO…
Exc1D Apr 29, 2026
14e86fd
feat(erasure): DeleteAccountFlow — two-step modal with typing gate
Exc1D Apr 29, 2026
7f5295f
feat(erasure): GoodbyeScreen and profile route wired to DeleteAccount…
Exc1D Apr 29, 2026
c4f2c2c
docs: Phase 8C complete — progress, learnings, and open SMS erasure gap
Exc1D Apr 29, 2026
3a214d5
fixup! fix(erasure): approve deny path — Auth re-enable, sentinel del…
Exc1D Apr 29, 2026
87d2bdc
fixup! feat(erasure): citizen PWA erasure service — callable wrapper …
Exc1D Apr 29, 2026
b986bfa
fixup! feat(erasure): Firestore rules — erasure_requests create/read,…
Exc1D Apr 29, 2026
3db4a09
fix: address CI rule coverage + Sourcery review comments
Exc1D Apr 29, 2026
2eee2ac
fix(coderabbit): address remaining PR #82 review comments
Exc1D Apr 29, 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
43 changes: 43 additions & 0 deletions apps/citizen-pwa/src/__tests__/erasure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

const { mockCallable, mockSignOut } = vi.hoisted(() => ({
mockCallable: vi.fn(),
mockSignOut: vi.fn(),
}))
vi.mock('../services/firebase.js', () => ({
fns: vi.fn(),
auth: vi.fn(),
httpsCallable: () => mockCallable,
}))
vi.mock('firebase/auth', () => ({ signOut: mockSignOut }))

import { requestDataErasureAndSignOut } from '../services/erasure.js'

beforeEach(() => {
mockCallable.mockClear()
mockSignOut.mockClear()
mockCallable.mockResolvedValue({ data: {} })
mockSignOut.mockResolvedValue(undefined)
})

describe('requestDataErasureAndSignOut', () => {
it('calls requestDataErasure callable then signOut', async () => {
await requestDataErasureAndSignOut()
expect(mockCallable).toHaveBeenCalledWith({})
expect(mockSignOut).toHaveBeenCalled()
})

it('throws if callable fails without calling signOut', async () => {
mockCallable.mockRejectedValueOnce({ code: 'internal' })
await expect(requestDataErasureAndSignOut()).rejects.toMatchObject({ code: 'internal' })
expect(mockSignOut).not.toHaveBeenCalled()
})

it('still calls signOut if already-exists error (prior request pending)', async () => {
mockCallable.mockRejectedValueOnce({ code: 'already-exists' })
// already-exists means a prior request is in-flight; treat as success
// so the user is signed out and the UI transitions to goodbye
await requestDataErasureAndSignOut()
expect(mockSignOut).toHaveBeenCalled()
})
})
89 changes: 89 additions & 0 deletions apps/citizen-pwa/src/components/DeleteAccountFlow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { DeleteAccountFlow } from './DeleteAccountFlow.js'

const { mockErasure } = vi.hoisted(() => ({ mockErasure: vi.fn() }))
vi.mock('../services/erasure.js', () => ({
requestDataErasureAndSignOut: (): Promise<void> => mockErasure() as Promise<void>,
}))

beforeEach(() => {
mockErasure.mockReset()
})

describe('DeleteAccountFlow', () => {
it('renders trigger button', () => {
render(<DeleteAccountFlow onGoodbye={vi.fn()} />)
expect(screen.getByRole('button', { name: /delete my account/i })).toBeDefined()
})

it('shows step-1 warning modal on trigger click', async () => {
const user = userEvent.setup()
render(<DeleteAccountFlow onGoodbye={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /delete my account/i }))
expect(screen.getByText(/delete your account/i)).toBeDefined()
expect(screen.getByRole('button', { name: /yes, delete my account/i })).toBeDefined()
})

it('shows step-2 typing gate after step-1 confirmation', async () => {
const user = userEvent.setup()
render(<DeleteAccountFlow onGoodbye={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /delete my account/i }))
await user.click(screen.getByRole('button', { name: /yes, delete my account/i }))
expect(screen.getByPlaceholderText(/type delete/i)).toBeDefined()
})

it('submit is disabled until user types DELETE', async () => {
const user = userEvent.setup()
render(<DeleteAccountFlow onGoodbye={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /delete my account/i }))
await user.click(screen.getByRole('button', { name: /yes, delete my account/i }))
const confirmBtn = screen.getByRole('button', { name: /confirm deletion/i })
expect(confirmBtn.hasAttribute('disabled')).toBe(true)
await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE')
expect(confirmBtn.hasAttribute('disabled')).toBe(false)
})

it('calls erasure service and onGoodbye on successful submission', async () => {
mockErasure.mockResolvedValueOnce(undefined)
const onGoodbye = vi.fn()
const user = userEvent.setup()
render(<DeleteAccountFlow onGoodbye={onGoodbye} />)
await user.click(screen.getByRole('button', { name: /delete my account/i }))
await user.click(screen.getByRole('button', { name: /yes, delete my account/i }))
await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE')
await user.click(screen.getByRole('button', { name: /confirm deletion/i }))
await waitFor(() => {
expect(onGoodbye).toHaveBeenCalled()
})
})

it('shows error message on callable failure', async () => {
mockErasure.mockRejectedValueOnce({ code: 'internal' })
const user = userEvent.setup()
render(<DeleteAccountFlow onGoodbye={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /delete my account/i }))
await user.click(screen.getByRole('button', { name: /yes, delete my account/i }))
await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE')
await user.click(screen.getByRole('button', { name: /confirm deletion/i }))
await waitFor(() => {
expect(screen.getByRole('alert')).toBeDefined()
})
})

it('calls onGoodbye when callable returns already-exists', async () => {
// Service swallows already-exists and resolves so user is signed out
mockErasure.mockResolvedValueOnce(undefined)
const onGoodbye = vi.fn()
const user = userEvent.setup()
render(<DeleteAccountFlow onGoodbye={onGoodbye} />)
await user.click(screen.getByRole('button', { name: /delete my account/i }))
await user.click(screen.getByRole('button', { name: /yes, delete my account/i }))
await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE')
await user.click(screen.getByRole('button', { name: /confirm deletion/i }))
await waitFor(() => {
expect(onGoodbye).toHaveBeenCalled()
})
})
})
95 changes: 95 additions & 0 deletions apps/citizen-pwa/src/components/DeleteAccountFlow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useState, useCallback } from 'react'
import { requestDataErasureAndSignOut } from '../services/erasure.js'

interface Props {
onGoodbye: () => void
}

type Step = 'idle' | 'warn' | 'confirm' | 'submitting'

export function DeleteAccountFlow({ onGoodbye }: Props) {
const [step, setStep] = useState<Step>('idle')
const [typed, setTyped] = useState('')
const [error, setError] = useState<string | null>(null)

const handleConfirm = useCallback(() => {
void (async () => {
setStep('submitting')
setError(null)
try {
await requestDataErasureAndSignOut()
onGoodbye()
} catch {
setError('Something went wrong. Your account has not been deleted. Please try again.')
setStep('confirm')
}
Comment on lines +22 to +25
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 | 🟠 Major

Handle idempotent erasure conflicts separately from generic failures.

All failures are collapsed to one generic error, so an already-exists erasure request is treated as hard failure instead of a recoverable/expected state.

Suggested fix
-      } catch {
-        setError('Something went wrong. Your account has not been deleted. Please try again.')
-        setStep('confirm')
+      } catch (err: unknown) {
+        const code =
+          typeof err === 'object' && err !== null && 'code' in err
+            ? String((err as { code?: unknown }).code)
+            : ''
+        if (code === 'already-exists') {
+          onGoodbye()
+          return
+        }
+        setError('Something went wrong. Your account has not been deleted. Please try again.')
+        setStep('confirm')
       }
📝 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
} catch {
setError('Something went wrong. Your account has not been deleted. Please try again.')
setStep('confirm')
}
} catch (err: unknown) {
const code =
typeof err === 'object' && err !== null && 'code' in err
? String((err as { code?: unknown }).code)
: ''
if (code === 'already-exists') {
onGoodbye()
return
}
setError('Something went wrong. Your account has not been deleted. Please try again.')
setStep('confirm')
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/citizen-pwa/src/components/DeleteAccountFlow.tsx` around lines 22 - 25,
The catch block in DeleteAccountFlow.tsx currently swallows all errors and sets
a generic failure via setError and setStep('confirm'); change the catch to
capture the error object (catch (err)) and branch on the erasure-conflict
sentinel (e.g. err.code === 'already-exists' or
err.message.includes('already-exists')) so that when an idempotent
"already-exists" erasure response occurs you treat it as a recoverable/expected
state (advance the flow or show a success/info message) instead of marking it as
a hard failure; keep the existing generic setError/setStep('confirm') behavior
for other errors.

})()
}, [onGoodbye])

const goIdle = useCallback(() => {
setStep('idle')
setTyped('')
setError(null)
}, [])

if (step === 'idle') {
return (
<button
onClick={() => {
setStep('warn')
setTyped('')
setError(null)
}}
style={{ color: 'red' }}
>
Delete my account
</button>
)
}

if (step === 'warn') {
return (
<div role="dialog" aria-modal="true" aria-labelledby="delete-warn-title">
<h2 id="delete-warn-title">Delete your account?</h2>
<p>This will permanently:</p>
<ul>
<li>Remove your name, contact info, and account</li>
<li>Anonymize your reports (they remain as public record)</li>
<li>Sign you out immediately</li>
</ul>
<p>This cannot be undone. Your request will be reviewed before deletion is complete.</p>
<button onClick={goIdle}>Cancel</button>
<button
onClick={() => {
setStep('confirm')
setTyped('')
setError(null)
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
Yes, delete my account →
</button>
</div>
)
}

return (
<div role="dialog" aria-modal="true" aria-labelledby="delete-confirm-title">
<h2 id="delete-confirm-title">Are you sure?</h2>
<label htmlFor="delete-confirm">Type DELETE to confirm</label>
<input
id="delete-confirm"
placeholder="Type DELETE"
value={typed}
onChange={(e) => {
setTyped(e.target.value)
}}
autoComplete="off"
/>
{error && <p role="alert">{error}</p>}
<button onClick={goIdle}>Cancel</button>
<button disabled={typed !== 'DELETE' || step === 'submitting'} onClick={handleConfirm}>
Confirm deletion
</button>
</div>
)
}
13 changes: 13 additions & 0 deletions apps/citizen-pwa/src/components/GoodbyeScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function GoodbyeScreen() {
return (
<main style={{ display: 'grid', placeItems: 'center', minHeight: '100dvh', padding: '2rem' }}>
<div style={{ textAlign: 'center', maxWidth: '360px' }}>
<h1>Request submitted</h1>
<p>
Your deletion request has been submitted. You have been signed out. You will not receive
further notifications.
</p>
</div>
</main>
)
}
20 changes: 18 additions & 2 deletions apps/citizen-pwa/src/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { createBrowserRouter, RouterProvider, useNavigate } from 'react-router-dom'
import { CitizenShell } from './components/CitizenShell.js'
import { MapTab } from './components/MapTab/index.js'
import { SubmitReportForm } from './components/SubmitReportForm/index.js'
import { ReceiptScreen } from './components/ReceiptScreen.js'
import { LookupScreen } from './components/LookupScreen.js'
import { TrackingScreen } from './components/TrackingScreen.js'
import { GoodbyeScreen } from './components/GoodbyeScreen.js'
import { DeleteAccountFlow } from './components/DeleteAccountFlow.js'

function StubTab({ label }: { label: string }) {
return (
Expand All @@ -14,6 +16,19 @@ function StubTab({ label }: { label: string }) {
)
}

function ProfileTab() {
const navigate = useNavigate()
return (
<div style={{ padding: '1.5rem' }}>
<h2>Profile</h2>
<section style={{ marginTop: '2rem', borderTop: '1px solid #eee', paddingTop: '1.5rem' }}>
<h3>Privacy</h3>
<DeleteAccountFlow onGoodbye={() => void navigate('/goodbye')} />
</section>
</div>
)
}

const router = createBrowserRouter([
{
path: '/',
Expand Down Expand Up @@ -53,12 +68,13 @@ const router = createBrowserRouter([
path: '/profile',
element: (
<CitizenShell>
<StubTab label="Profile" />
<ProfileTab />
</CitizenShell>
),
},
{ path: '/receipt', element: <ReceiptScreen /> },
{ path: '/lookup', element: <LookupScreen /> },
{ path: '/goodbye', element: <GoodbyeScreen />, handle: { hideBottomNav: true } },
])

export function AppRoutes() {
Expand Down
12 changes: 12 additions & 0 deletions apps/citizen-pwa/src/services/erasure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { signOut } from 'firebase/auth'
import { auth, fns, httpsCallable } from './firebase.js'

export async function requestDataErasureAndSignOut(): Promise<void> {
try {
await httpsCallable(fns(), 'requestDataErasure')({})
} catch (err: unknown) {
const code = (err as { code?: string }).code
if (code !== 'already-exists') throw err
}
await signOut(auth())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
10 changes: 10 additions & 0 deletions docs/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@
- `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.

## Phase 8C — RA 10173 Erasure

- Write Firestore doc before disabling Firebase Auth in erasure callables — if Auth disable fails, doc deletion is the rollback, not Auth re-enable. Simpler invariant with no side effects on write failure.
- `erasure_active/{uid}` sentinel pattern makes the concurrent double-submission race atomic via Firestore transaction. Status checks alone are not sufficient (TOCTOU).
- `erasureSweep` must be sequential (claim one, process, then claim next). Bulk-claiming multiple records lets timeouts strand unclaimed records in `executing` state permanently until the 30-min staleness window.
- Auth hard-delete must be the last step in erasure execution — it is the only non-reversible step and must not precede any Firestore/Storage operation that could fail.
- `retentionSweep` must exclude reports belonging to citizens with active erasure requests via an in-memory UID set. Firestore does not support cross-collection NOT IN filters.
- `retentionHardDeleteEligibleAt` as a queryable field (set at anonymization time + 30 days) avoids the "find a deleted document" problem for the 1-month threshold query.
- `sms_inbox` join is via `senderMsisdnHash` field directly — not via a session ID foreign key. Verify field names against actual schema before implementing SMS nulling steps.

## Misc

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

## Current — Phase 7 Provincial Superadmin + NDRRMC + Break-Glass
## Current — Phase 8C — RA 10173 Erasure & Anonymization

### 8C — RA 10173 Erasure Execution — COMPLETE

| Task | Status | Notes |
| ------------------------------- | ------- | ---------------------------------------------------------- |
| requestDataErasure callable | ✅ DONE | Sentinel idempotency, write-before-disable order |
| approveErasureRequest deny path | ✅ DONE | Auth re-enable, sentinel delete, transaction gate |
| setErasureLegalHold callable | ✅ DONE | Superadmin + MFA, terminal status guard |
| erasureSweep (15 min) | ✅ DONE | Sequential claim, all-or-nothing, dead-letter path |
| retentionSweep (daily) | ✅ DONE | 1-week anonymize, 1-month hard-delete, active erasure skip |
| Firestore rules | ✅ DONE | erasure_requests, erasure_active, auditLog |
| Storage rules | ✅ DONE | Citizen read-own media |
| Citizen PWA delete-account flow | ✅ DONE | Two-step modal, GoodbyeScreen, /goodbye route |

**Open production launch blocker:** Pseudonymous erasure gap (RA 10173 §16) — citizens who submitted via SMS before registering have no erasure path for pre-registration sms_inbox/sms_sessions data. SMS onboarding path cannot go to production until this is resolved. Requires UID-linkage mechanism at registration time.

---

## Phase 7 Provincial Superadmin + NDRRMC + Break-Glass

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

Expand Down
Loading
Loading