diff --git a/apps/admin-desktop/src/App.tsx b/apps/admin-desktop/src/App.tsx index 7119309c..605de12e 100644 --- a/apps/admin-desktop/src/App.tsx +++ b/apps/admin-desktop/src/App.tsx @@ -1,10 +1,11 @@ import { RouterProvider } from 'react-router-dom' -import { AuthProvider } from './app/auth-provider' +import { AuthProvider } from '@bantayog/shared-ui' +import { auth } from './app/firebase' import { router } from './routes' export default function App() { return ( - + ) diff --git a/apps/admin-desktop/src/app/auth-provider.tsx b/apps/admin-desktop/src/app/auth-provider.tsx deleted file mode 100644 index 605a0fb1..00000000 --- a/apps/admin-desktop/src/app/auth-provider.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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 - refreshClaims: () => Promise -} - -const Ctx = createContext(null) - -export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null) - const [claims, setClaims] = useState(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 ( - fbSignOut(auth), refreshClaims }}> - {children} - - ) -} - -export function useAuth() { - const v = useContext(Ctx) - if (!v) throw new Error('useAuth must be used inside AuthProvider') - return v -} diff --git a/apps/admin-desktop/src/app/protected-route.tsx b/apps/admin-desktop/src/app/protected-route.tsx deleted file mode 100644 index 979e205f..00000000 --- a/apps/admin-desktop/src/app/protected-route.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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
Loading…
- if (!user) return - - if (claims?.role !== 'municipal_admin' && claims?.role !== 'provincial_superadmin') { - return ( -
- You don't have admin access on this account. Contact your municipality's - superadmin. -
- ) - } - if (claims.active !== true) { - return
Your account is not active. Please contact your superadmin.
- } - if (claims.role === 'municipal_admin' && !claims.municipalityId) { - return ( -
- Your admin account is missing a municipality assignment. Contact superadmin. -
- ) - } - - return <>{children} -} diff --git a/apps/admin-desktop/src/pages/DispatchModal.tsx b/apps/admin-desktop/src/pages/DispatchModal.tsx index e85224db..cdae1b59 100644 --- a/apps/admin-desktop/src/pages/DispatchModal.tsx +++ b/apps/admin-desktop/src/pages/DispatchModal.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useAuth } from '../app/auth-provider' +import { useAuth } from '@bantayog/shared-ui' import { useEligibleResponders } from '../hooks/useEligibleResponders' import { callables } from '../services/callables' @@ -13,7 +13,9 @@ export function DispatchModal({ onError: (msg: string) => void }) { const { claims } = useAuth() - const eligible = useEligibleResponders(claims?.municipalityId) + const municipalityId = + typeof claims?.municipalityId === 'string' ? claims.municipalityId : undefined + const eligible = useEligibleResponders(municipalityId) const [picked, setPicked] = useState(null) const [submitting, setSubmitting] = useState(false) diff --git a/apps/admin-desktop/src/pages/TriageQueuePage.tsx b/apps/admin-desktop/src/pages/TriageQueuePage.tsx index 078300b2..80f60a8c 100644 --- a/apps/admin-desktop/src/pages/TriageQueuePage.tsx +++ b/apps/admin-desktop/src/pages/TriageQueuePage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useAuth } from '../app/auth-provider' +import { useAuth } from '@bantayog/shared-ui' import { useMuniReports } from '../hooks/useMuniReports' import { ReportDetailPanel } from './ReportDetailPanel' import { DispatchModal } from './DispatchModal' @@ -8,7 +8,9 @@ import { callables } from '../services/callables' export function TriageQueuePage() { const { claims, signOut } = useAuth() - const { rows, loading, error } = useMuniReports(claims?.municipalityId) + const municipalityId = + typeof claims?.municipalityId === 'string' ? claims.municipalityId : undefined + const { rows, loading, error } = useMuniReports(municipalityId) const [selected, setSelected] = useState(null) const [dispatchForReportId, setDispatchForReportId] = useState(null) const [closeForReportId, setCloseForReportId] = useState(null) @@ -50,7 +52,7 @@ export function TriageQueuePage() { return (
-

Triage · {claims?.municipalityId ?? 'N/A'}

+

Triage · {municipalityId ?? 'N/A'}

{banner &&
{banner}
} diff --git a/apps/admin-desktop/src/routes.tsx b/apps/admin-desktop/src/routes.tsx index 40eb66d1..fb9dabc2 100644 --- a/apps/admin-desktop/src/routes.tsx +++ b/apps/admin-desktop/src/routes.tsx @@ -1,5 +1,5 @@ import { createBrowserRouter } from 'react-router-dom' -import { ProtectedRoute } from './app/protected-route' +import { ProtectedRoute } from '@bantayog/shared-ui' import { LoginPage } from './pages/LoginPage' import { TriageQueuePage } from './pages/TriageQueuePage' @@ -8,7 +8,16 @@ export const router = createBrowserRouter([ { path: '/', element: ( - + + You do not have access to this page. Please contact your superadmin. + + } + > ), diff --git a/apps/citizen-pwa/src/components/SubmitReportForm/BarangaySelector.test.tsx b/apps/citizen-pwa/src/components/SubmitReportForm/BarangaySelector.test.tsx new file mode 100644 index 00000000..7d5c2017 --- /dev/null +++ b/apps/citizen-pwa/src/components/SubmitReportForm/BarangaySelector.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { BarangaySelector } from './BarangaySelector.js' + +describe('BarangaySelector', () => { + it('renders nothing when municipalityId is empty', () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('renders barangay options for the given municipality', () => { + render() + + const select = screen.getByRole('combobox') + expect(select).toBeInTheDocument() + + const options = screen.getAllByRole('option') + expect(options[0]).toHaveTextContent('Select barangay (optional)...') + + // Daet has specific barangays — spot-check a couple + expect(screen.getByRole('option', { name: 'Alawihao' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Bagasbas' })).toBeInTheDocument() + }) + + it('calls onChange when selection changes', () => { + const onChange = vi.fn() + render() + + const select = screen.getByRole('combobox') + fireEvent.change(select, { target: { value: 'Bagasbas' } }) + + expect(onChange).toHaveBeenCalledExactlyOnceWith('Bagasbas') + }) + + it('shows optional label', () => { + render() + + expect(screen.getByText(/— optional/)).toBeInTheDocument() + }) + + it('reflects the value prop on the select element', () => { + render() + + const select = screen.getByRole('combobox') + expect(select).toHaveValue('Bagasbas') + }) +}) diff --git a/apps/citizen-pwa/src/components/SubmitReportForm/BarangaySelector.tsx b/apps/citizen-pwa/src/components/SubmitReportForm/BarangaySelector.tsx new file mode 100644 index 00000000..99fff1a7 --- /dev/null +++ b/apps/citizen-pwa/src/components/SubmitReportForm/BarangaySelector.tsx @@ -0,0 +1,44 @@ +import { useMemo } from 'react' +import { FALLBACK_BARANGAYS, MUNICIPALITY_LABELS } from './location-constants.js' + +interface BarangaySelectorProps { + municipalityId: string + value: string + onChange: (barangayId: string) => void +} + +export function BarangaySelector({ municipalityId, value, onChange }: BarangaySelectorProps) { + const barangayOptions = useMemo(() => { + if (!municipalityId) return [] + return FALLBACK_BARANGAYS.filter( + (b) => MUNICIPALITY_LABELS[municipalityId] === b.municipality, + ).sort((a, b) => a.name.localeCompare(b.name)) + }, [municipalityId]) + + if (!municipalityId) { + return null + } + + return ( +
+

+ Barangay + — optional +

+ +
+ ) +} diff --git a/apps/citizen-pwa/src/components/SubmitReportForm/ContactFields.test.tsx b/apps/citizen-pwa/src/components/SubmitReportForm/ContactFields.test.tsx new file mode 100644 index 00000000..dea5093b --- /dev/null +++ b/apps/citizen-pwa/src/components/SubmitReportForm/ContactFields.test.tsx @@ -0,0 +1,151 @@ +import { describe, it, expect, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { ContactFields } from './ContactFields.js' + +function renderContactFields(overrides: Partial[0]> = {}) { + const defaults = { + reporterName: '', + onReporterNameChange: vi.fn(), + nameError: null as string | null, + onNameErrorClear: vi.fn(), + reporterMsisdn: '', + onReporterMsisdnChange: vi.fn(), + phoneError: null as string | null, + onPhoneErrorClear: vi.fn(), + anyoneHurt: false, + onAnyoneHurtChange: vi.fn(), + patientCount: 0, + onPatientCountChange: vi.fn(), + } + + return render() +} + +describe('ContactFields', () => { + it('renders all fields', () => { + renderContactFields() + + expect(screen.getByPlaceholderText('Maria Dela Cruz')).toBeInTheDocument() + expect(screen.getByPlaceholderText('+63 912 345 6789')).toBeInTheDocument() + expect(screen.getByText('Is anyone hurt?')).toBeInTheDocument() + }) + + it('calls onReporterNameChange and onNameErrorClear on name input change', () => { + const onReporterNameChange = vi.fn() + const onNameErrorClear = vi.fn() + + renderContactFields({ onReporterNameChange, onNameErrorClear }) + + const input = screen.getByPlaceholderText('Maria Dela Cruz') + fireEvent.change(input, { target: { value: 'Juan' } }) + + expect(onReporterNameChange).toHaveBeenCalledExactlyOnceWith('Juan') + expect(onNameErrorClear).toHaveBeenCalledTimes(1) + }) + + it('calls onReporterMsisdnChange and onPhoneErrorClear on phone input change', () => { + const onReporterMsisdnChange = vi.fn() + const onPhoneErrorClear = vi.fn() + + renderContactFields({ onReporterMsisdnChange, onPhoneErrorClear }) + + const input = screen.getByPlaceholderText('+63 912 345 6789') + fireEvent.change(input, { target: { value: '+639123456789' } }) + + expect(onReporterMsisdnChange).toHaveBeenCalledExactlyOnceWith('+639123456789') + expect(onPhoneErrorClear).toHaveBeenCalledTimes(1) + }) + + it('toggles "Yes" for anyone hurt', () => { + const onAnyoneHurtChange = vi.fn() + + renderContactFields({ onAnyoneHurtChange }) + + const yesButton = screen.getByRole('button', { name: 'Yes' }) + fireEvent.click(yesButton) + + expect(onAnyoneHurtChange).toHaveBeenCalledExactlyOnceWith(true) + }) + + it('toggles "No" for anyone hurt', () => { + const onAnyoneHurtChange = vi.fn() + + renderContactFields({ anyoneHurt: true, onAnyoneHurtChange }) + + const noButton = screen.getByRole('button', { name: 'No' }) + fireEvent.click(noButton) + + expect(onAnyoneHurtChange).toHaveBeenCalledExactlyOnceWith(false) + }) + + it('does not show patient counter when anyoneHurt is false', () => { + renderContactFields({ anyoneHurt: false }) + + expect(screen.queryByText('How many patients?')).not.toBeInTheDocument() + }) + + it('increments patient count', () => { + const onPatientCountChange = vi.fn() + + renderContactFields({ anyoneHurt: true, patientCount: 2, onPatientCountChange }) + + const incrementButton = screen.getByRole('button', { name: '+' }) + fireEvent.click(incrementButton) + + expect(onPatientCountChange).toHaveBeenCalledExactlyOnceWith(3) + }) + + it('decrements patient count', () => { + const onPatientCountChange = vi.fn() + + renderContactFields({ anyoneHurt: true, patientCount: 2, onPatientCountChange }) + + const decrementButton = screen.getByRole('button', { name: '−' }) + fireEvent.click(decrementButton) + + expect(onPatientCountChange).toHaveBeenCalledExactlyOnceWith(1) + }) + + it('disables decrement button at patient count 0', () => { + renderContactFields({ anyoneHurt: true, patientCount: 0 }) + + const decrementButton = screen.getByRole('button', { name: '−' }) + expect(decrementButton).toBeDisabled() + }) + + it('shows memory hint when hasMemory is true', () => { + renderContactFields({ hasMemory: true }) + + expect(screen.getByText('Pre-filled from your last report')).toBeInTheDocument() + }) + + it('does not show memory hint when hasMemory is false', () => { + const { container } = renderContactFields({ hasMemory: false }) + + expect(container.querySelector('.memory-hint')).not.toBeInTheDocument() + }) + + it('displays name error when provided', () => { + renderContactFields({ nameError: 'Name is required' }) + + expect(screen.getByTestId('name-error')).toHaveTextContent('Name is required') + }) + + it('displays phone error when provided', () => { + renderContactFields({ phoneError: 'Invalid phone number' }) + + expect(screen.getByTestId('phone-error')).toHaveTextContent('Invalid phone number') + }) + + it('does not display name error when null', () => { + renderContactFields({ nameError: null }) + + expect(screen.queryByTestId('name-error')).not.toBeInTheDocument() + }) + + it('does not display phone error when null', () => { + renderContactFields({ phoneError: null }) + + expect(screen.queryByTestId('phone-error')).not.toBeInTheDocument() + }) +}) diff --git a/apps/citizen-pwa/src/components/SubmitReportForm/ContactFields.tsx b/apps/citizen-pwa/src/components/SubmitReportForm/ContactFields.tsx new file mode 100644 index 00000000..79dbf79a --- /dev/null +++ b/apps/citizen-pwa/src/components/SubmitReportForm/ContactFields.tsx @@ -0,0 +1,147 @@ +interface ContactFieldsProps { + reporterName: string + onReporterNameChange: (name: string) => void + nameError: string | null + onNameErrorClear: () => void + + reporterMsisdn: string + onReporterMsisdnChange: (msisdn: string) => void + phoneError: string | null + onPhoneErrorClear: () => void + + anyoneHurt: boolean + onAnyoneHurtChange: (hurt: boolean) => void + + patientCount: number + onPatientCountChange: (count: number) => void + + hasMemory?: boolean +} + +export function ContactFields({ + reporterName, + onReporterNameChange, + nameError, + onNameErrorClear, + reporterMsisdn, + onReporterMsisdnChange, + phoneError, + onPhoneErrorClear, + anyoneHurt, + onAnyoneHurtChange, + patientCount, + onPatientCountChange, + hasMemory = false, +}: ContactFieldsProps) { + return ( + <> + {hasMemory &&

Pre-filled from your last report

} +
+ + { + onReporterNameChange(e.target.value) + onNameErrorClear() + }} + placeholder="Maria Dela Cruz" + className="text-input" + required + /> + {nameError && ( +

+ {nameError} +

+ )} +
+ +
+ + { + onReporterMsisdnChange(e.target.value) + onPhoneErrorClear() + }} + placeholder="+63 912 345 6789" + className="text-input" + required + /> + {phoneError && ( +

+ {phoneError} +

+ )} +

+ Gives you faster help. Admins call this number if they need more details.{' '} + Mas mabilis kang matutulungan. +

+
+ +
+

+ Is anyone hurt? + May injured ba? +

+
+ + +
+ + {anyoneHurt && ( +
+

How many patients?

+
+ +
{patientCount}
+ +
+
+ )} +
+ + ) +} diff --git a/apps/citizen-pwa/src/components/SubmitReportForm/MunicipalitySelector.test.tsx b/apps/citizen-pwa/src/components/SubmitReportForm/MunicipalitySelector.test.tsx new file mode 100644 index 00000000..1c0b26d7 --- /dev/null +++ b/apps/citizen-pwa/src/components/SubmitReportForm/MunicipalitySelector.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { MunicipalitySelector } from './MunicipalitySelector.js' +import { MUNI_LABELS_SORTED } from './location-constants.js' + +describe('MunicipalitySelector', () => { + it('renders with sorted municipality options', () => { + render() + + const select = screen.getByRole('combobox') + expect(select).toBeInTheDocument() + + // Should have default option + all municipalities + const options = screen.getAllByRole('option') + expect(options[0]).toHaveTextContent('Select municipality...') + expect(options).toHaveLength(1 + MUNI_LABELS_SORTED.length) + + // Verify sorted order: first should be Basud alphabetically + expect(options[1]).toHaveTextContent('Basud') + }) + + it('calls onChange when selection changes', () => { + const onChange = vi.fn() + render() + + const select = screen.getByRole('combobox') + fireEvent.change(select, { target: { value: 'daet' } }) + + expect(onChange).toHaveBeenCalledExactlyOnceWith('daet') + }) + + it('displays error message when provided', () => { + render( + , + ) + + expect(screen.getByText('Please select a municipality')).toBeInTheDocument() + }) + + it('does not display error when error is undefined', () => { + const { container } = render() + + expect(container.querySelector('.field-error')).not.toBeInTheDocument() + }) + + it('does not display error when error is null', () => { + const { container } = render() + + expect(container.querySelector('.field-error')).not.toBeInTheDocument() + }) + + it('reflects the value prop on the select element', () => { + render() + + const select = screen.getByRole('combobox') + expect(select).toHaveValue('daet') + }) +}) diff --git a/apps/citizen-pwa/src/components/SubmitReportForm/MunicipalitySelector.tsx b/apps/citizen-pwa/src/components/SubmitReportForm/MunicipalitySelector.tsx new file mode 100644 index 00000000..905518c9 --- /dev/null +++ b/apps/citizen-pwa/src/components/SubmitReportForm/MunicipalitySelector.tsx @@ -0,0 +1,30 @@ +import { MUNI_LABELS_SORTED } from './location-constants.js' + +interface MunicipalitySelectorProps { + value: string + onChange: (municipalityId: string) => void + error?: string | null +} + +export function MunicipalitySelector({ value, onChange, error }: MunicipalitySelectorProps) { + return ( +
+

Municipality

+ + {error ?

{error}

: null} +
+ ) +} diff --git a/apps/citizen-pwa/src/components/SubmitReportForm/Step2WhoWhere.tsx b/apps/citizen-pwa/src/components/SubmitReportForm/Step2WhoWhere.tsx index af989e72..218c75d2 100644 --- a/apps/citizen-pwa/src/components/SubmitReportForm/Step2WhoWhere.tsx +++ b/apps/citizen-pwa/src/components/SubmitReportForm/Step2WhoWhere.tsx @@ -2,311 +2,12 @@ import { useState, useEffect } from 'react' import { MapPin, Navigation, ArrowLeft } from 'lucide-react' import { Button } from '../ui/Button' import { CAMARINES_NORTE_MUNICIPALITIES } from '@bantayog/shared-validators' - -const FALLBACK_BARANGAYS: { name: string; municipality: string }[] = [ - { name: 'Angas', municipality: 'Basud' }, - { name: 'Bactas', municipality: 'Basud' }, - { name: 'Binatagan', municipality: 'Basud' }, - { name: 'Caayunan', municipality: 'Basud' }, - { name: 'Guinatungan', municipality: 'Basud' }, - { name: 'Hinampacan', municipality: 'Basud' }, - { name: 'Langa', municipality: 'Basud' }, - { name: 'Laniton', municipality: 'Basud' }, - { name: 'Lidong', municipality: 'Basud' }, - { name: 'Mampili', municipality: 'Basud' }, - { name: 'Mandazo', municipality: 'Basud' }, - { name: 'Mangcamagong', municipality: 'Basud' }, - { name: 'Manmuntay', municipality: 'Basud' }, - { name: 'Mantugawe', municipality: 'Basud' }, - { name: 'Matnog', municipality: 'Basud' }, - { name: 'Mocong', municipality: 'Basud' }, - { name: 'Oliva', municipality: 'Basud' }, - { name: 'Pagsangahan', municipality: 'Basud' }, - { name: 'Pinagwarasan', municipality: 'Basud' }, - { name: 'Plaridel', municipality: 'Basud' }, - { name: 'Poblacion 1', municipality: 'Basud' }, - { name: 'Poblacion 2', municipality: 'Basud' }, - { name: 'San Felipe', municipality: 'Basud' }, - { name: 'San Jose', municipality: 'Basud' }, - { name: 'San Pascual', municipality: 'Basud' }, - { name: 'Taba-taba', municipality: 'Basud' }, - { name: 'Tacad', municipality: 'Basud' }, - { name: 'Taisan', municipality: 'Basud' }, - { name: 'Tuaca', municipality: 'Basud' }, - { name: 'Alayao', municipality: 'Capalonga' }, - { name: 'Binawangan', municipality: 'Capalonga' }, - { name: 'Calabaca', municipality: 'Capalonga' }, - { name: 'Camagsaan', municipality: 'Capalonga' }, - { name: 'Catabaguangan', municipality: 'Capalonga' }, - { name: 'Catioan', municipality: 'Capalonga' }, - { name: 'Del Pilar', municipality: 'Capalonga' }, - { name: 'Itok', municipality: 'Capalonga' }, - { name: 'Lucbanan', municipality: 'Capalonga' }, - { name: 'Mabini', municipality: 'Capalonga' }, - { name: 'Mactang', municipality: 'Capalonga' }, - { name: 'Magsaysay', municipality: 'Capalonga' }, - { name: 'Mataque', municipality: 'Capalonga' }, - { name: 'Old Camp', municipality: 'Capalonga' }, - { name: 'Poblacion', municipality: 'Capalonga' }, - { name: 'San Antonio', municipality: 'Capalonga' }, - { name: 'San Isidro', municipality: 'Capalonga' }, - { name: 'San Roque', municipality: 'Capalonga' }, - { name: 'Tanawan', municipality: 'Capalonga' }, - { name: 'Ubang', municipality: 'Capalonga' }, - { name: 'Villa Aurora', municipality: 'Capalonga' }, - { name: 'Villa Belen', municipality: 'Capalonga' }, - { name: 'Alawihao', municipality: 'Daet' }, - { name: 'Awitan', municipality: 'Daet' }, - { name: 'Bagasbas', municipality: 'Daet' }, - { name: 'Barangay I', municipality: 'Daet' }, - { name: 'Barangay II', municipality: 'Daet' }, - { name: 'Barangay III', municipality: 'Daet' }, - { name: 'Barangay IV', municipality: 'Daet' }, - { name: 'Barangay V', municipality: 'Daet' }, - { name: 'Barangay VI', municipality: 'Daet' }, - { name: 'Barangay VII', municipality: 'Daet' }, - { name: 'Barangay VIII', municipality: 'Daet' }, - { name: 'Bibirao', municipality: 'Daet' }, - { name: 'Borabod', municipality: 'Daet' }, - { name: 'Calasgasan', municipality: 'Daet' }, - { name: 'Camambugan', municipality: 'Daet' }, - { name: 'Cobangbang', municipality: 'Daet' }, - { name: 'Dogongan', municipality: 'Daet' }, - { name: 'Gahonon', municipality: 'Daet' }, - { name: 'Gubat', municipality: 'Daet' }, - { name: 'Lag-on', municipality: 'Daet' }, - { name: 'Magang', municipality: 'Daet' }, - { name: 'Mambalite', municipality: 'Daet' }, - { name: 'Mancruz', municipality: 'Daet' }, - { name: 'Pamorangon', municipality: 'Daet' }, - { name: 'San Isidro', municipality: 'Daet' }, - { name: 'Bagong Bayan', municipality: 'Jose Panganiban' }, - { name: 'Calero', municipality: 'Jose Panganiban' }, - { name: 'Dahican', municipality: 'Jose Panganiban' }, - { name: 'Dayhagan', municipality: 'Jose Panganiban' }, - { name: 'Larap', municipality: 'Jose Panganiban' }, - { name: 'Luklukan Norte', municipality: 'Jose Panganiban' }, - { name: 'Luklukan Sur', municipality: 'Jose Panganiban' }, - { name: 'Motherlode', municipality: 'Jose Panganiban' }, - { name: 'Nakalaya', municipality: 'Jose Panganiban' }, - { name: 'North Poblacion', municipality: 'Jose Panganiban' }, - { name: 'Osmeña', municipality: 'Jose Panganiban' }, - { name: 'Pag-asa', municipality: 'Jose Panganiban' }, - { name: 'Parang', municipality: 'Jose Panganiban' }, - { name: 'Plaridel', municipality: 'Jose Panganiban' }, - { name: 'Salvacion', municipality: 'Jose Panganiban' }, - { name: 'San Isidro', municipality: 'Jose Panganiban' }, - { name: 'San Jose', municipality: 'Jose Panganiban' }, - { name: 'San Martin', municipality: 'Jose Panganiban' }, - { name: 'San Pedro', municipality: 'Jose Panganiban' }, - { name: 'San Rafael', municipality: 'Jose Panganiban' }, - { name: 'Santa Cruz', municipality: 'Jose Panganiban' }, - { name: 'Santa Elena', municipality: 'Jose Panganiban' }, - { name: 'Santa Milagrosa', municipality: 'Jose Panganiban' }, - { name: 'Santa Rosa Norte', municipality: 'Jose Panganiban' }, - { name: 'Santa Rosa Sur', municipality: 'Jose Panganiban' }, - { name: 'South Poblacion', municipality: 'Jose Panganiban' }, - { name: 'Tamisan', municipality: 'Jose Panganiban' }, - { name: 'Anahaw', municipality: 'Labo' }, - { name: 'Anameam', municipality: 'Labo' }, - { name: 'Awitan', municipality: 'Labo' }, - { name: 'Baay', municipality: 'Labo' }, - { name: 'Bagacay', municipality: 'Labo' }, - { name: 'Bagong Silang I', municipality: 'Labo' }, - { name: 'Bagong Silang II', municipality: 'Labo' }, - { name: 'Bagong Silang III', municipality: 'Labo' }, - { name: 'Bakiad', municipality: 'Labo' }, - { name: 'Bautista', municipality: 'Labo' }, - { name: 'Bayabas', municipality: 'Labo' }, - { name: 'Bayan-bayan', municipality: 'Labo' }, - { name: 'Benit', municipality: 'Labo' }, - { name: 'Bulhao', municipality: 'Labo' }, - { name: 'Cabatuhan', municipality: 'Labo' }, - { name: 'Cabusay', municipality: 'Labo' }, - { name: 'Calabasa', municipality: 'Labo' }, - { name: 'Canapawan', municipality: 'Labo' }, - { name: 'Daguit', municipality: 'Labo' }, - { name: 'Dalas', municipality: 'Labo' }, - { name: 'Dumagmang', municipality: 'Labo' }, - { name: 'Exciban', municipality: 'Labo' }, - { name: 'Fundado', municipality: 'Labo' }, - { name: 'Guinacutan', municipality: 'Labo' }, - { name: 'Guisican', municipality: 'Labo' }, - { name: 'Gumamela', municipality: 'Labo' }, - { name: 'Iberica', municipality: 'Labo' }, - { name: 'Kalamunding', municipality: 'Labo' }, - { name: 'Lugui', municipality: 'Labo' }, - { name: 'Mabilo I', municipality: 'Labo' }, - { name: 'Mabilo II', municipality: 'Labo' }, - { name: 'Macogon', municipality: 'Labo' }, - { name: 'Mahawan-hawan', municipality: 'Labo' }, - { name: 'Malangcao-Basud', municipality: 'Labo' }, - { name: 'Malasugui', municipality: 'Labo' }, - { name: 'Malatap', municipality: 'Labo' }, - { name: 'Malaya', municipality: 'Labo' }, - { name: 'Malibago', municipality: 'Labo' }, - { name: 'Maot', municipality: 'Labo' }, - { name: 'Masalong', municipality: 'Labo' }, - { name: 'Matanlang', municipality: 'Labo' }, - { name: 'Napaod', municipality: 'Labo' }, - { name: 'Pag-asa', municipality: 'Labo' }, - { name: 'Pangpang', municipality: 'Labo' }, - { name: 'Pinya', municipality: 'Labo' }, - { name: 'San Antonio', municipality: 'Labo' }, - { name: 'San Francisco', municipality: 'Labo' }, - { name: 'Santa Cruz', municipality: 'Labo' }, - { name: 'Submakin', municipality: 'Labo' }, - { name: 'Talobatib', municipality: 'Labo' }, - { name: 'Tigbinan', municipality: 'Labo' }, - { name: 'Tulay na Lupa', municipality: 'Labo' }, - { name: 'Apuao', municipality: 'Mercedes' }, - { name: 'Barangay I', municipality: 'Mercedes' }, - { name: 'Barangay II', municipality: 'Mercedes' }, - { name: 'Barangay III', municipality: 'Mercedes' }, - { name: 'Barangay IV', municipality: 'Mercedes' }, - { name: 'Barangay V', municipality: 'Mercedes' }, - { name: 'Barangay VI', municipality: 'Mercedes' }, - { name: 'Barangay VII', municipality: 'Mercedes' }, - { name: 'Caringo', municipality: 'Mercedes' }, - { name: 'Catandunganon', municipality: 'Mercedes' }, - { name: 'Cayucyucan', municipality: 'Mercedes' }, - { name: 'Colasi', municipality: 'Mercedes' }, - { name: 'Del Rosario', municipality: 'Mercedes' }, - { name: 'Gaboc', municipality: 'Mercedes' }, - { name: 'Hamoraon', municipality: 'Mercedes' }, - { name: 'Hinipaan', municipality: 'Mercedes' }, - { name: 'Lalawigan', municipality: 'Mercedes' }, - { name: 'Lanot', municipality: 'Mercedes' }, - { name: 'Mambungalon', municipality: 'Mercedes' }, - { name: 'Manguisoc', municipality: 'Mercedes' }, - { name: 'Masalongsalong', municipality: 'Mercedes' }, - { name: 'Matoogtoog', municipality: 'Mercedes' }, - { name: 'Pambuhan', municipality: 'Mercedes' }, - { name: 'Quinapaguian', municipality: 'Mercedes' }, - { name: 'San Roque', municipality: 'Mercedes' }, - { name: 'Tarum', municipality: 'Mercedes' }, - { name: 'Awitan', municipality: 'Paracale' }, - { name: 'Bagumbayan', municipality: 'Paracale' }, - { name: 'Bakal', municipality: 'Paracale' }, - { name: 'Batobalani', municipality: 'Paracale' }, - { name: 'Calaburnay', municipality: 'Paracale' }, - { name: 'Capacuan', municipality: 'Paracale' }, - { name: 'Casalugan', municipality: 'Paracale' }, - { name: 'Dagang', municipality: 'Paracale' }, - { name: 'Dalnac', municipality: 'Paracale' }, - { name: 'Dancalan', municipality: 'Paracale' }, - { name: 'Gumaus', municipality: 'Paracale' }, - { name: 'Labnig', municipality: 'Paracale' }, - { name: 'Macolabo Island', municipality: 'Paracale' }, - { name: 'Malacbang', municipality: 'Paracale' }, - { name: 'Malaguit', municipality: 'Paracale' }, - { name: 'Mampungo', municipality: 'Paracale' }, - { name: 'Mangkasay', municipality: 'Paracale' }, - { name: 'Maybato', municipality: 'Paracale' }, - { name: 'Palanas', municipality: 'Paracale' }, - { name: 'Pinagbirayan Malaki', municipality: 'Paracale' }, - { name: 'Pinagbirayan Munti', municipality: 'Paracale' }, - { name: 'Poblacion Norte', municipality: 'Paracale' }, - { name: 'Poblacion Sur', municipality: 'Paracale' }, - { name: 'Tabas', municipality: 'Paracale' }, - { name: 'Talusan', municipality: 'Paracale' }, - { name: 'Tawig', municipality: 'Paracale' }, - { name: 'Tugos', municipality: 'Paracale' }, - { name: 'Daculang Bolo', municipality: 'San Lorenzo Ruiz' }, - { name: 'Dagotdotan', municipality: 'San Lorenzo Ruiz' }, - { name: 'Langga', municipality: 'San Lorenzo Ruiz' }, - { name: 'Laniton', municipality: 'San Lorenzo Ruiz' }, - { name: 'Maisog', municipality: 'San Lorenzo Ruiz' }, - { name: 'Mampurog', municipality: 'San Lorenzo Ruiz' }, - { name: 'Manlimonsito', municipality: 'San Lorenzo Ruiz' }, - { name: 'Matacong', municipality: 'San Lorenzo Ruiz' }, - { name: 'Salvacion', municipality: 'San Lorenzo Ruiz' }, - { name: 'San Antonio', municipality: 'San Lorenzo Ruiz' }, - { name: 'San Isidro', municipality: 'San Lorenzo Ruiz' }, - { name: 'San Ramon', municipality: 'San Lorenzo Ruiz' }, - { name: 'Asdum', municipality: 'San Vicente' }, - { name: 'Cabanbanan', municipality: 'San Vicente' }, - { name: 'Calabagas', municipality: 'San Vicente' }, - { name: 'Fabrica', municipality: 'San Vicente' }, - { name: 'Iraya Sur', municipality: 'San Vicente' }, - { name: 'Man-ogob', municipality: 'San Vicente' }, - { name: 'Poblacion District I', municipality: 'San Vicente' }, - { name: 'Poblacion District II', municipality: 'San Vicente' }, - { name: 'San Jose', municipality: 'San Vicente' }, - { name: 'Basiad', municipality: 'Santa Elena' }, - { name: 'Bulala', municipality: 'Santa Elena' }, - { name: 'Don Tomas', municipality: 'Santa Elena' }, - { name: 'Guitol', municipality: 'Santa Elena' }, - { name: 'Kabuluan', municipality: 'Santa Elena' }, - { name: 'Kagtalaba', municipality: 'Santa Elena' }, - { name: 'Maulawin', municipality: 'Santa Elena' }, - { name: 'Patag Ibaba', municipality: 'Santa Elena' }, - { name: 'Patag Iraya', municipality: 'Santa Elena' }, - { name: 'Plaridel', municipality: 'Santa Elena' }, - { name: 'Polungguitguit', municipality: 'Santa Elena' }, - { name: 'Rizal', municipality: 'Santa Elena' }, - { name: 'Salvacion', municipality: 'Santa Elena' }, - { name: 'San Lorenzo', municipality: 'Santa Elena' }, - { name: 'San Pedro', municipality: 'Santa Elena' }, - { name: 'San Vicente', municipality: 'Santa Elena' }, - { name: 'Santa Elena', municipality: 'Santa Elena' }, - { name: 'Tabugon', municipality: 'Santa Elena' }, - { name: 'Villa San Isidro', municipality: 'Santa Elena' }, - { name: 'Binanuaan', municipality: 'Talisay' }, - { name: 'Caawigan', municipality: 'Talisay' }, - { name: 'Cahabaan', municipality: 'Talisay' }, - { name: 'Calintaan', municipality: 'Talisay' }, - { name: 'Del Carmen', municipality: 'Talisay' }, - { name: 'Gabon', municipality: 'Talisay' }, - { name: 'Itomang', municipality: 'Talisay' }, - { name: 'Poblacion', municipality: 'Talisay' }, - { name: 'San Francisco', municipality: 'Talisay' }, - { name: 'San Isidro', municipality: 'Talisay' }, - { name: 'San Jose', municipality: 'Talisay' }, - { name: 'San Nicolas', municipality: 'Talisay' }, - { name: 'Santa Cruz', municipality: 'Talisay' }, - { name: 'Santa Elena', municipality: 'Talisay' }, - { name: 'Santo Niño', municipality: 'Talisay' }, - { name: 'Aguit-it', municipality: 'Vinzons' }, - { name: 'Banocboc', municipality: 'Vinzons' }, - { name: 'Barangay I', municipality: 'Vinzons' }, - { name: 'Barangay II', municipality: 'Vinzons' }, - { name: 'Barangay III', municipality: 'Vinzons' }, - { name: 'Cagbalogo', municipality: 'Vinzons' }, - { name: 'Calangcawan Norte', municipality: 'Vinzons' }, - { name: 'Calangcawan Sur', municipality: 'Vinzons' }, - { name: 'Guinacutan', municipality: 'Vinzons' }, - { name: 'Mangcawayan', municipality: 'Vinzons' }, - { name: 'Mangcayo', municipality: 'Vinzons' }, - { name: 'Manlucugan', municipality: 'Vinzons' }, - { name: 'Matango', municipality: 'Vinzons' }, - { name: 'Napilihan', municipality: 'Vinzons' }, - { name: 'Pinagtigasan', municipality: 'Vinzons' }, - { name: 'Sabang', municipality: 'Vinzons' }, - { name: 'Santo Domingo', municipality: 'Vinzons' }, - { name: 'Singi', municipality: 'Vinzons' }, - { name: 'Sula', municipality: 'Vinzons' }, -] - -const MUNICIPALITY_LABELS = Object.fromEntries( - CAMARINES_NORTE_MUNICIPALITIES.map((m) => [m.id, m.label]), -) - -const MUNI_LABELS_SORTED = [...CAMARINES_NORTE_MUNICIPALITIES] - .sort((a, b) => a.label.localeCompare(b.label)) - .map((m) => ({ id: m.id, label: m.label })) - -function isQuotaExceededError(err: unknown): boolean { - return ( - err instanceof DOMException && - // eslint-disable-next-line @typescript-eslint/no-deprecated - (err.name === 'QuotaExceededError' || err.code === 22) - ) -} - -function isSecurityError(err: unknown): boolean { - return err instanceof DOMException && err.name === 'SecurityError' -} +import { isQuotaExceededError, isSecurityError } from '../../utils/storage-errors' +import { useGpsLocation } from '../../hooks/useGpsLocation' +import { useMunicipalityBarangays } from '../../hooks/useMunicipalityBarangays' +import { MunicipalitySelector } from './MunicipalitySelector' +import { BarangaySelector } from './BarangaySelector' +import { ContactFields } from './ContactFields' interface Step2WhoWhereProps { onNext: (data: { @@ -325,58 +26,32 @@ interface Step2WhoWhereProps { } export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2WhoWhereProps) { - const [locationMethod, setLocationMethod] = useState<'gps' | 'manual' | null>(null) - const [location, setLocation] = useState<{ lat: number; lng: number } | null>(null) - const [gpsLoading, setGpsLoading] = useState(false) - const [selectedMunicipalityId, setSelectedMunicipalityId] = useState('') - const [selectedBarangayId, setSelectedBarangayId] = useState(undefined) + const { + location, + locationMethod, + isLoading: gpsLoading, + locationError, + attemptGps, + resetGps, + setLocationMethod, + } = useGpsLocation(true) + + const { + selectedMunicipalityId, + selectedBarangayId, + handleSelectMunicipality, + handleSelectBarangay, + } = useMunicipalityBarangays() + const [nearestLandmark, setNearestLandmark] = useState('') const [reporterName, setReporterName] = useState('') const [reporterMsisdn, setReporterMsisdn] = useState('') const [anyoneHurt, setAnyoneHurt] = useState(false) const [patientCount, setPatientCount] = useState(0) - const [locationError, setLocationError] = useState(null) const [nameError, setNameError] = useState(null) const [phoneError, setPhoneError] = useState(null) const [hasMemory, setHasMemory] = useState(false) - - const attemptGps = async () => { - setLocationError(null) - setGpsLoading(true) - try { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!navigator.geolocation) { - setLocationError('GPS not supported on this device.') - setLocationMethod('manual') - return - } - const pos = await new Promise((resolve, reject) => { - navigator.geolocation.getCurrentPosition(resolve, reject, { - enableHighAccuracy: true, - timeout: 10000, - }) - }) - setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }) - setLocationMethod('gps') - } catch (err: unknown) { - console.error('[Step2WhoWhere] attemptGps failed:', err) - let msg = 'Could not get location. Choose municipality manually.' - if (err && typeof err === 'object' && 'code' in err) { - const code = (err as GeolocationPositionError).code - if (code === 1) msg = 'Location access denied. Choose municipality manually.' - else if (code === 3) msg = 'Location timed out. Choose municipality manually.' - } - setLocationError(msg) - setLocationMethod('manual') - } finally { - setGpsLoading(false) - } - } - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - void attemptGps() - }, []) + const [municipalityError, setMunicipalityError] = useState(null) useEffect(() => { try { @@ -399,17 +74,13 @@ export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2Who } }, []) - const handleSelectMunicipality = (muniId: string) => { - setSelectedMunicipalityId(muniId) - setSelectedBarangayId(undefined) - } - const handleNext = () => { setNameError(null) setPhoneError(null) + setMunicipalityError(null) if (locationMethod === 'manual' && !selectedMunicipalityId) { - setLocationError('Please select your municipality.') + setMunicipalityError('Please select a municipality.') return } if (!reporterName.trim()) { @@ -468,12 +139,6 @@ export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2Who (locationMethod === 'manual' && !!selectedMunicipalityId) || false - const barangayOptions = selectedMunicipalityId - ? FALLBACK_BARANGAYS.filter( - (b) => MUNICIPALITY_LABELS[selectedMunicipalityId] === b.municipality, - ).sort((a, b) => a.name.localeCompare(b.name)) - : [] - return (
@@ -500,7 +165,6 @@ export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2Who className="location-picker-btn" onClick={() => { void attemptGps() - setGpsLoading(true) }} > @@ -533,8 +197,7 @@ export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2Who type="button" className="location-btn" onClick={() => { - setLocationMethod(null) - setLocation(null) + resetGps() }} >
@@ -556,49 +219,23 @@ export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2Who ) : null} {locationMethod === 'manual' ? ( -
-

Municipality

- - {locationError &&

{locationError}

} -
+ { + setMunicipalityError(null) + handleSelectMunicipality(muniId) + }} + error={municipalityError} + /> ) : null} - {locationMethod === 'manual' && selectedMunicipalityId ? ( -
-

- Barangay - — optional -

- -
- ) : null} + {locationMethod === 'manual' && ( + + )} {locationMethod === 'manual' && selectedMunicipalityId ? (
@@ -621,97 +258,25 @@ export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2Who {locationMethod !== null ? ( <> - {hasMemory &&

Pre-filled from your last report

} -
-

Your name

- { - setReporterName(e.target.value) - setNameError(null) - }} - placeholder="Maria Dela Cruz" - className="text-input" - required - /> - {nameError &&

{nameError}

} -
- -
-

Phone number

- { - setReporterMsisdn(e.target.value) - setPhoneError(null) - }} - placeholder="+63 912 345 6789" - className="text-input" - required - /> - {phoneError &&

{phoneError}

} -

- Gives you faster help. Admins call this number if they need more - details. Mas mabilis kang matutulungan. -

-
- -
-

- Is anyone hurt? - May injured ba? -

-
- - -
- - {anyoneHurt && ( -
-

How many patients?

-
- -
{patientCount}
- -
-
- )} -
+ { + setNameError(null) + }} + reporterMsisdn={reporterMsisdn} + onReporterMsisdnChange={setReporterMsisdn} + phoneError={phoneError} + onPhoneErrorClear={() => { + setPhoneError(null) + }} + anyoneHurt={anyoneHurt} + onAnyoneHurtChange={setAnyoneHurt} + patientCount={patientCount} + onPatientCountChange={setPatientCount} + hasMemory={hasMemory} + /> + +
+ ) +} + +describe('AuthProvider', () => { + beforeEach(() => { + mockOnAuthStateChanged = vi.fn() + mockSignOut = vi.fn().mockResolvedValue(undefined) + }) + + it('shows ready with no user when auth state is null', async () => { + const unsubscribe = vi.fn() + mockOnAuthStateChanged.mockImplementation((_auth, cb) => { + cb(null) + return unsubscribe + }) + + const mockAuth = { currentUser: null } as unknown as import('firebase/auth').Auth + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('loading').textContent).toBe('ready') + }) + expect(screen.getByTestId('user').textContent).toBe('none') + expect(screen.getByTestId('claims').textContent).toBe('none') + }) + + it('sets user and claims when authenticated', async () => { + const unsubscribe = vi.fn() + const mockUser = { + uid: 'test-uid', + getIdTokenResult: vi.fn().mockResolvedValue({ + claims: { role: 'responder', municipalityId: 'daet' }, + }), + } + + mockOnAuthStateChanged.mockImplementation((_auth, cb) => { + cb(mockUser) + return unsubscribe + }) + + const mockAuth = { currentUser: mockUser } as unknown as import('firebase/auth').Auth + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('loading').textContent).toBe('ready') + }) + expect(screen.getByTestId('user').textContent).toBe('test-uid') + expect(screen.getByTestId('claims').textContent).toBe( + JSON.stringify({ role: 'responder', municipalityId: 'daet' }), + ) + }) + + it('calls signOut when signOut button is clicked', async () => { + mockOnAuthStateChanged.mockImplementation((_auth, cb) => { + cb(null) + return vi.fn() + }) + + const mockAuth = { currentUser: null } as unknown as import('firebase/auth').Auth + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('loading').textContent).toBe('ready') + }) + + act(() => { + screen.getByTestId('signout').click() + }) + + expect(mockSignOut).toHaveBeenCalledWith(mockAuth) + }) + + it('refreshes claims when refreshClaims is called', async () => { + const unsubscribe = vi.fn() + const mockUser = { + uid: 'test-uid', + getIdTokenResult: vi.fn().mockResolvedValue({ + claims: { role: 'responder' }, + }), + } + + mockOnAuthStateChanged.mockImplementation((_auth, cb) => { + cb(mockUser) + return unsubscribe + }) + + const mockAuth = { currentUser: mockUser } as unknown as import('firebase/auth').Auth + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('loading').textContent).toBe('ready') + }) + expect(screen.getByTestId('claims').textContent).toBe(JSON.stringify({ role: 'responder' })) + + mockUser.getIdTokenResult.mockResolvedValue({ + claims: { role: 'responder', municipalityId: 'daet' }, + }) + + act(() => { + screen.getByTestId('refresh').click() + }) + + await waitFor(() => { + expect(screen.getByTestId('claims').textContent).toBe( + JSON.stringify({ role: 'responder', municipalityId: 'daet' }), + ) + }) + }) + + it('sets claims to null when getIdTokenResult fails', async () => { + const unsubscribe = vi.fn() + const mockUser = { + uid: 'test-uid', + getIdTokenResult: vi.fn().mockRejectedValue(new Error('token expired')), + } + + mockOnAuthStateChanged.mockImplementation((_auth, cb) => { + cb(mockUser) + return unsubscribe + }) + + const mockAuth = { currentUser: mockUser } as unknown as import('firebase/auth').Auth + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('loading').textContent).toBe('ready') + }) + expect(screen.getByTestId('claims').textContent).toBe('none') + }) + + it('throws when useAuth is called outside AuthProvider', () => { + function BadComponent() { + useAuth() + return null + } + + expect(() => render()).toThrow('useAuth must be used inside ') + }) +}) diff --git a/apps/responder-app/src/app/auth-provider.tsx b/packages/shared-ui/src/auth-provider.tsx similarity index 57% rename from apps/responder-app/src/app/auth-provider.tsx rename to packages/shared-ui/src/auth-provider.tsx index 8f8e2d60..878e4470 100644 --- a/apps/responder-app/src/app/auth-provider.tsx +++ b/packages/shared-ui/src/auth-provider.tsx @@ -1,21 +1,44 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' -import { onAuthStateChanged, type User } from 'firebase/auth' -import { auth } from './firebase' +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react' +import { onAuthStateChanged, signOut as fbSignOut, type Auth, type User } from 'firebase/auth' -interface AuthContextValue { +export interface AuthContextValue { user: User | null claims: Record | null loading: boolean signOut: () => Promise + refreshClaims: () => Promise } const AuthContext = createContext(null) -export function AuthProvider({ children }: { children: ReactNode }) { +interface AuthProviderProps { + children: ReactNode + auth: Auth +} + +export function AuthProvider({ children, auth }: AuthProviderProps) { const [user, setUser] = useState(null) const [claims, setClaims] = useState | null>(null) const [loading, setLoading] = useState(true) + const refreshClaims = useCallback(async () => { + const currentUser = auth.currentUser + if (!currentUser) { + setClaims(null) + return + } + try { + const token = await currentUser.getIdTokenResult(true) + // Guard against stale user (sign-out or account switch during refresh) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (auth.currentUser?.uid !== currentUser.uid) return + setClaims(token.claims as Record) + } catch (err: unknown) { + console.error('[AuthProvider] token refresh failed:', err) + setClaims(null) + } + }, [auth]) + useEffect(() => { let active = true const unsub = onAuthStateChanged(auth, (u) => { @@ -46,15 +69,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { active = false unsub() } - }, []) + }, [auth]) - async function signOut() { - const { signOut: fbSignOut } = await import('firebase/auth') + const signOut = useCallback(async () => { await fbSignOut(auth) - } + }, [auth]) return ( - + {children} ) diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index 04914a33..2cfd6036 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -1,2 +1,3 @@ -// Primitive components filled in Phase 2+. -export {} +export { AuthProvider, useAuth } from './auth-provider.js' +export type { AuthContextValue } from './auth-provider.js' +export { ProtectedRoute } from './protected-route.js' diff --git a/packages/shared-ui/src/protected-route.test.tsx b/packages/shared-ui/src/protected-route.test.tsx new file mode 100644 index 00000000..22aa9b48 --- /dev/null +++ b/packages/shared-ui/src/protected-route.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { ProtectedRoute } from './protected-route.js' +import { AuthProvider } from './auth-provider.js' + +let mockOnAuthStateChanged = vi.fn() + +vi.mock('firebase/auth', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + onAuthStateChanged: (...args: unknown[]) => mockOnAuthStateChanged(...args), + signOut: vi.fn().mockResolvedValue(undefined), +})) + +function TestApp({ + auth, + initialEntry = '/', +}: { + auth: import('firebase/auth').Auth + initialEntry?: string +}) { + return ( + + + + Login
} /> + +
Protected
+ + } + /> + + + + ) +} + +describe('ProtectedRoute', () => { + beforeEach(() => { + mockOnAuthStateChanged = vi.fn() + }) + + it('redirects to login when not authenticated', async () => { + mockOnAuthStateChanged.mockImplementation((_auth, cb) => { + cb(null) + return vi.fn() + }) + + const mockAuth = { currentUser: null } as unknown as import('firebase/auth').Auth + render() + + expect(await screen.findByTestId('login')).toBeDefined() + }) + + it('renders children when user has allowed role', async () => { + const mockUser = { + uid: 'u1', + getIdTokenResult: vi.fn().mockResolvedValue({ + claims: { role: 'responder' }, + }), + } + + mockOnAuthStateChanged.mockImplementation((_auth, cb) => { + cb(mockUser) + return vi.fn() + }) + + const mockAuth = { currentUser: mockUser } as unknown as import('firebase/auth').Auth + render() + + expect(await screen.findByTestId('protected')).toBeDefined() + }) + + it('renders unauthorized fallback when role is not allowed', async () => { + const mockUser = { + uid: 'u1', + getIdTokenResult: vi.fn().mockResolvedValue({ + claims: { role: 'municipal_admin' }, + }), + } + + mockOnAuthStateChanged.mockImplementation((_auth, cb) => { + cb(mockUser) + return vi.fn() + }) + + const mockAuth = { currentUser: mockUser } as unknown as import('firebase/auth').Auth + render() + + expect(await screen.findByText('Access denied.')).toBeDefined() + }) + + it('renders unauthorized when requireActive is true and user is inactive', async () => { + const mockUser = { + uid: 'u1', + getIdTokenResult: vi.fn().mockResolvedValue({ + claims: { role: 'responder', active: false }, + }), + } + + mockOnAuthStateChanged.mockImplementation((_auth, cb) => { + cb(mockUser) + return vi.fn() + }) + + const mockAuth = { currentUser: mockUser } as unknown as import('firebase/auth').Auth + render( + + + + +
Protected
+ + } + /> +
+
+
, + ) + + expect(await screen.findByText('Access denied.')).toBeDefined() + }) + + it('renders unauthorized when municipalityId is required but missing', async () => { + const mockUser = { + uid: 'u1', + getIdTokenResult: vi.fn().mockResolvedValue({ + claims: { role: 'municipal_admin', active: true }, + }), + } + + mockOnAuthStateChanged.mockImplementation((_auth, cb) => { + cb(mockUser) + return vi.fn() + }) + + const mockAuth = { currentUser: mockUser } as unknown as import('firebase/auth').Auth + render( + + + + +
Protected
+ + } + /> +
+
+
, + ) + + expect(await screen.findByText('Access denied.')).toBeDefined() + }) +}) diff --git a/packages/shared-ui/src/protected-route.tsx b/packages/shared-ui/src/protected-route.tsx new file mode 100644 index 00000000..82943fcb --- /dev/null +++ b/packages/shared-ui/src/protected-route.tsx @@ -0,0 +1,45 @@ +import { type ReactNode } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from './auth-provider.js' + +interface ProtectedRouteProps { + children: ReactNode + allowedRoles: string[] + requireActive?: boolean + requireMunicipalityIdForRoles?: string[] + loadingFallback?: ReactNode + unauthorizedFallback?: ReactNode +} + +export function ProtectedRoute({ + children, + allowedRoles, + requireActive = false, + requireMunicipalityIdForRoles = [], + loadingFallback =
Loading…
, + unauthorizedFallback =
Access denied.
, +}: ProtectedRouteProps) { + const { user, claims, loading } = useAuth() + const location = useLocation() + + if (loading) return loadingFallback + if (!user) return + + const role = typeof claims?.role === 'string' ? claims.role : '' + if (!allowedRoles.includes(role)) { + return unauthorizedFallback + } + + if (requireActive && claims?.active !== true) { + return unauthorizedFallback + } + + if ( + requireMunicipalityIdForRoles.includes(role) && + (typeof claims?.municipalityId !== 'string' || !claims.municipalityId.trim()) + ) { + return unauthorizedFallback + } + + return <>{children} +} diff --git a/packages/shared-ui/vitest.config.ts b/packages/shared-ui/vitest.config.ts new file mode 100644 index 00000000..5eaa352f --- /dev/null +++ b/packages/shared-ui/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'happy-dom', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + setupFiles: ['@testing-library/jest-dom/vitest'], + }, +}) diff --git a/packages/shared-validators/lib/logging.d.ts.map b/packages/shared-validators/lib/logging.d.ts.map index 9971bb1b..f1f7e0fe 100644 --- a/packages/shared-validators/lib/logging.d.ts.map +++ b/packages/shared-validators/lib/logging.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"logging.d.ts","sourceRoot":"","sources":["../src/logging.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,+EAA+E;AAC/E,eAAO,MAAM,iBAAiB,MAAM,CAAA;AAEpC;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,UAAU,CAAA;AAE7E;;;GAGG;AACH,MAAM,WAAW,QAAQ;IACvB,0DAA0D;IAC1D,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,WAAW,CAAA;IACrB;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAA;IACb,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC/B;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE;IAC9B,QAAQ,EAAE,WAAW,CAAA;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC/B,GAAG,QAAQ,CAgCX;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,IACpC,OAAO,IAAI,CAAC,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,WAAW,CAAC,KAAG,QAAQ,CAE5E"} \ No newline at end of file +{"version":3,"file":"logging.d.ts","sourceRoot":"","sources":["../src/logging.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,+EAA+E;AAC/E,eAAO,MAAM,iBAAiB,MAAM,CAAA;AAEpC;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,UAAU,CAAA;AAE7E;;;GAGG;AACH,MAAM,WAAW,QAAQ;IACvB,0DAA0D;IAC1D,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,WAAW,CAAA;IACrB;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAA;IACb,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC/B;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE;IAC9B,QAAQ,EAAE,WAAW,CAAA;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC/B,GAAG,QAAQ,CAiCX;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,IACpC,OAAO,IAAI,CAAC,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,WAAW,CAAC,KAAG,QAAQ,CAE5E"} \ No newline at end of file diff --git a/packages/shared-validators/lib/logging.js b/packages/shared-validators/lib/logging.js index 4a724ef9..cfcc738e 100644 --- a/packages/shared-validators/lib/logging.js +++ b/packages/shared-validators/lib/logging.js @@ -37,8 +37,9 @@ export function logEvent(entry) { console.warn(json); } else if (entry.severity === 'INFO') { + // Cloud Functions ingests stdout as structured JSON logs. // eslint-disable-next-line no-console - console.log(json); + console.info(json); } else { // DEBUG and any other value diff --git a/packages/shared-validators/lib/logging.js.map b/packages/shared-validators/lib/logging.js.map index b171ec74..7fde5b7e 100644 --- a/packages/shared-validators/lib/logging.js.map +++ b/packages/shared-validators/lib/logging.js.map @@ -1 +1 @@ -{"version":3,"file":"logging.js","sourceRoot":"","sources":["../src/logging.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,+EAA+E;AAC/E,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAG,CAAA;AAuCpC;;;;;;;GAOG;AACH,MAAM,UAAU,QAAQ,CAAC,KAMxB;IACC,MAAM,SAAS,GACb,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,iBAAiB;QACxC,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC,EAAE,iBAAiB,CAAC;QACjD,CAAC,CAAC,KAAK,CAAC,SAAS,CAAA;IAErB,MAAM,QAAQ,GAAa;QACzB,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;QACrB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,KAAK,EAAE,KAAK,CAAC,IAAI;QACjB,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,SAAS;QACT,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC1D,CAAA;IAED,+EAA+E;IAC/E,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAA;IACrC,IAAI,KAAK,CAAC,QAAQ,KAAK,OAAO,IAAI,KAAK,CAAC,QAAQ,KAAK,UAAU,EAAE,CAAC;QAChE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACrB,CAAC;SAAM,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;QACxC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACpB,CAAC;SAAM,IAAI,KAAK,CAAC,QAAQ,KAAK,MAAM,EAAE,CAAC;QACrC,sCAAsC;QACtC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;IACnB,CAAC;SAAM,CAAC;QACN,4BAA4B;QAC5B,sCAAsC;QACtC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACrB,CAAC;IAED,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,SAAiB;IAC5C,OAAO,CAAC,KAAwD,EAAY,EAAE,CAC5E,QAAQ,CAAC,EAAE,GAAG,KAAK,EAAE,SAAS,EAAE,CAAC,CAAA;AACrC,CAAC"} \ No newline at end of file +{"version":3,"file":"logging.js","sourceRoot":"","sources":["../src/logging.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,+EAA+E;AAC/E,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAG,CAAA;AAuCpC;;;;;;;GAOG;AACH,MAAM,UAAU,QAAQ,CAAC,KAMxB;IACC,MAAM,SAAS,GACb,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,iBAAiB;QACxC,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC,EAAE,iBAAiB,CAAC;QACjD,CAAC,CAAC,KAAK,CAAC,SAAS,CAAA;IAErB,MAAM,QAAQ,GAAa;QACzB,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;QACrB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,KAAK,EAAE,KAAK,CAAC,IAAI;QACjB,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,SAAS;QACT,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC1D,CAAA;IAED,+EAA+E;IAC/E,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAA;IACrC,IAAI,KAAK,CAAC,QAAQ,KAAK,OAAO,IAAI,KAAK,CAAC,QAAQ,KAAK,UAAU,EAAE,CAAC;QAChE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACrB,CAAC;SAAM,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;QACxC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACpB,CAAC;SAAM,IAAI,KAAK,CAAC,QAAQ,KAAK,MAAM,EAAE,CAAC;QACrC,0DAA0D;QAC1D,sCAAsC;QACtC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACpB,CAAC;SAAM,CAAC;QACN,4BAA4B;QAC5B,sCAAsC;QACtC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACrB,CAAC;IAED,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,SAAiB;IAC5C,OAAO,CAAC,KAAwD,EAAY,EAAE,CAC5E,QAAQ,CAAC,EAAE,GAAG,KAAK,EAAE,SAAS,EAAE,CAAC,CAAA;AACrC,CAAC"} \ No newline at end of file diff --git a/packages/shared-validators/lib/sms-templates.d.ts.map b/packages/shared-validators/lib/sms-templates.d.ts.map index 37e94d00..82b9d09d 100644 --- a/packages/shared-validators/lib/sms-templates.d.ts.map +++ b/packages/shared-validators/lib/sms-templates.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"sms-templates.d.ts","sourceRoot":"","sources":["../src/sms-templates.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,UAAU,GAClB,aAAa,GACb,cAAc,GACd,eAAe,GACf,YAAY,GACZ,gBAAgB,CAAA;AACpB,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,IAAI,CAAA;AAEnC,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AAED,UAAU,UAAU;IAClB,OAAO,EAAE,UAAU,CAAA;IACnB,MAAM,EAAE,SAAS,CAAA;IACjB,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAA;CAC5B;AA2BD,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAiBvD"} \ No newline at end of file +{"version":3,"file":"sms-templates.d.ts","sourceRoot":"","sources":["../src/sms-templates.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,UAAU,GAClB,aAAa,GACb,cAAc,GACd,eAAe,GACf,YAAY,GACZ,gBAAgB,CAAA;AACpB,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,IAAI,CAAA;AAEnC,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AAED,UAAU,UAAU;IAClB,OAAO,EAAE,UAAU,CAAA;IACnB,MAAM,EAAE,SAAS,CAAA;IACjB,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAA;CAC5B;AA2BD,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAiBvD"} \ No newline at end of file diff --git a/packages/shared-validators/lib/sms-templates.js b/packages/shared-validators/lib/sms-templates.js index 2c94cedb..2c8e91a5 100644 --- a/packages/shared-validators/lib/sms-templates.js +++ b/packages/shared-validators/lib/sms-templates.js @@ -1,4 +1,5 @@ -// TODO(phase-5): move template bodies to Firestore for CMS-driven editing. +// TICKET(BANTAYOG-PHASE6): move TEMPLATES to Firestore for CMS-driven editing. +// This requires an admin UI, caching strategy, and fallback chain — defer until post-MVP. export class SmsTemplateError extends Error { constructor(message) { super(message); diff --git a/packages/shared-validators/lib/sms-templates.js.map b/packages/shared-validators/lib/sms-templates.js.map index 49d7c460..badeeb17 100644 --- a/packages/shared-validators/lib/sms-templates.js.map +++ b/packages/shared-validators/lib/sms-templates.js.map @@ -1 +1 @@ -{"version":3,"file":"sms-templates.js","sourceRoot":"","sources":["../src/sms-templates.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAU3E,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IACzC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAA;IAChC,CAAC;CACF;AAQD,MAAM,SAAS,GAAkD;IAC/D,WAAW,EAAE;QACX,EAAE,EAAE,+FAA+F;QACnG,EAAE,EAAE,gGAAgG;KACrG;IACD,YAAY,EAAE;QACZ,EAAE,EAAE,8FAA8F;QAClG,EAAE,EAAE,gFAAgF;KACrF;IACD,aAAa,EAAE;QACb,EAAE,EAAE,kFAAkF;QACtF,EAAE,EAAE,qFAAqF;KAC1F;IACD,UAAU,EAAE;QACV,EAAE,EAAE,4EAA4E;QAChF,EAAE,EAAE,yEAAyE;KAC9E;IACD,cAAc,EAAE;QACd,EAAE,EAAE,+FAA+F;QACnG,EAAE,EAAE,+FAA+F;KACpG;CACF,CAAA;AAED,MAAM,aAAa,GAAG,eAAe,CAAA;AAErC,MAAM,UAAU,cAAc,CAAC,IAAgB;IAC7C,MAAM,UAAU,GAAG,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;IAC1C,mFAAmF;IACnF,uEAAuE;IACvE,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,gBAAgB,CAAC,oBAAoB,IAAI,CAAC,OAAO,EAAE,CAAC,CAAA;IAChE,CAAC;IACD,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IACxC,mFAAmF;IAEnF,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,gBAAgB,CAAC,mBAAmB,IAAI,CAAC,MAAM,EAAE,CAAC,CAAA;IAC9D,CAAC;IACD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;QACrE,MAAM,IAAI,gBAAgB,CAAC,8BAA8B,CAAC,CAAA;IAC5D,CAAC;IACD,OAAO,QAAQ,CAAC,OAAO,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;AAC7D,CAAC"} \ No newline at end of file +{"version":3,"file":"sms-templates.js","sourceRoot":"","sources":["../src/sms-templates.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,0FAA0F;AAU1F,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IACzC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAA;IAChC,CAAC;CACF;AAQD,MAAM,SAAS,GAAkD;IAC/D,WAAW,EAAE;QACX,EAAE,EAAE,+FAA+F;QACnG,EAAE,EAAE,gGAAgG;KACrG;IACD,YAAY,EAAE;QACZ,EAAE,EAAE,8FAA8F;QAClG,EAAE,EAAE,gFAAgF;KACrF;IACD,aAAa,EAAE;QACb,EAAE,EAAE,kFAAkF;QACtF,EAAE,EAAE,qFAAqF;KAC1F;IACD,UAAU,EAAE;QACV,EAAE,EAAE,4EAA4E;QAChF,EAAE,EAAE,yEAAyE;KAC9E;IACD,cAAc,EAAE;QACd,EAAE,EAAE,+FAA+F;QACnG,EAAE,EAAE,+FAA+F;KACpG;CACF,CAAA;AAED,MAAM,aAAa,GAAG,eAAe,CAAA;AAErC,MAAM,UAAU,cAAc,CAAC,IAAgB;IAC7C,MAAM,UAAU,GAAG,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;IAC1C,mFAAmF;IACnF,uEAAuE;IACvE,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,gBAAgB,CAAC,oBAAoB,IAAI,CAAC,OAAO,EAAE,CAAC,CAAA;IAChE,CAAC;IACD,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IACxC,mFAAmF;IAEnF,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,gBAAgB,CAAC,mBAAmB,IAAI,CAAC,MAAM,EAAE,CAAC,CAAA;IAC9D,CAAC;IACD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;QACrE,MAAM,IAAI,gBAAgB,CAAC,8BAA8B,CAAC,CAAA;IAC5D,CAAC;IACD,OAAO,QAAQ,CAAC,OAAO,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;AAC7D,CAAC"} \ No newline at end of file diff --git a/packages/shared-validators/src/logging.ts b/packages/shared-validators/src/logging.ts index 62a1f839..f8f88966 100644 --- a/packages/shared-validators/src/logging.ts +++ b/packages/shared-validators/src/logging.ts @@ -83,8 +83,9 @@ export function logEvent(entry: { } else if (entry.severity === 'WARNING') { console.warn(json) } else if (entry.severity === 'INFO') { + // Cloud Functions ingests stdout as structured JSON logs. // eslint-disable-next-line no-console - console.log(json) + console.info(json) } else { // DEBUG and any other value // eslint-disable-next-line no-console diff --git a/packages/shared-validators/src/sms-templates.ts b/packages/shared-validators/src/sms-templates.ts index e5482d7a..602e99f4 100644 --- a/packages/shared-validators/src/sms-templates.ts +++ b/packages/shared-validators/src/sms-templates.ts @@ -1,4 +1,5 @@ -// TODO(phase-5): move template bodies to Firestore for CMS-driven editing. +// TICKET(BANTAYOG-PHASE6): move TEMPLATES to Firestore for CMS-driven editing. +// This requires an admin UI, caching strategy, and fallback chain — defer until post-MVP. export type SmsPurpose = | 'receipt_ack' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e3c39ad..be29965f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -308,12 +308,37 @@ importers: packages/shared-ui: dependencies: + firebase: + specifier: ^12.0.0 + version: 12.12.0 react: - specifier: ^18.3.1 - version: 18.3.1 + specifier: ^18.3.1 || ^19.0.0 + version: 19.2.5 react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) + specifier: ^18.3.1 || ^19.0.0 + version: 19.2.5(react@19.2.5) + react-router-dom: + specifier: ^7.0.0 + version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.4.0 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + happy-dom: + specifier: ^15.11.0 + version: 15.11.7 + vitest: + specifier: ^4.1.4 + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(happy-dom@15.11.7)(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3)) packages/shared-validators: dependencies: @@ -2457,6 +2482,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -2924,6 +2953,10 @@ packages: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} + happy-dom@15.11.7: + resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} + engines: {node: '>=18.0.0'} + happy-dom@20.9.0: resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} engines: {node: '>=20.0.0'} @@ -4001,11 +4034,6 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} - peerDependencies: - react: ^18.3.1 - react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: @@ -4044,10 +4072,6 @@ packages: react-dom: optional: true - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} - engines: {node: '>=0.10.0'} - react@19.2.5: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} @@ -4148,9 +4172,6 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4691,6 +4712,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + websocket-driver@0.7.4: resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} engines: {node: '>=0.8.0'} @@ -7199,6 +7224,8 @@ snapshots: once: 1.4.0 optional: true + entities@4.5.0: {} + entities@7.0.1: {} env-paths@2.2.1: {} @@ -7957,6 +7984,12 @@ snapshots: - supports-color optional: true + happy-dom@15.11.7: + dependencies: + entities: 4.5.0 + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + happy-dom@20.9.0: dependencies: '@types/node': 20.19.39 @@ -9244,12 +9277,6 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - react-dom@18.3.1(react@18.3.1): - dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 - react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 @@ -9282,10 +9309,6 @@ snapshots: optionalDependencies: react-dom: 19.2.5(react@19.2.5) - react@18.3.1: - dependencies: - loose-envify: 1.4.0 - react@19.2.5: {} readable-stream@3.6.2: @@ -9417,10 +9440,6 @@ snapshots: sax@1.6.0: {} - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - scheduler@0.27.0: {} semver@6.3.1: {} @@ -10026,6 +10045,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(happy-dom@15.11.7)(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.4 + '@vitest/mocker': 4.1.4(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.4 + '@vitest/runner': 4.1.4 + '@vitest/snapshot': 4.1.4 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 25.6.0 + '@vitest/coverage-v8': 4.1.4(vitest@4.1.4) + happy-dom: 15.11.7 + transitivePeerDependencies: + - msw + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(happy-dom@20.9.0)(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.4 @@ -10067,6 +10116,8 @@ snapshots: webidl-conversions@3.0.1: optional: true + webidl-conversions@7.0.0: {} + websocket-driver@0.7.4: dependencies: http-parser-js: 0.5.10