diff --git a/__mocks__/expo-crypto.ts b/__mocks__/expo-crypto.ts new file mode 100644 index 00000000..8d4e0039 --- /dev/null +++ b/__mocks__/expo-crypto.ts @@ -0,0 +1,24 @@ +// Jest stand-in for expo-crypto, whose native AES classes cannot load under Jest. Randomness and digests use Node's +// crypto so PKCE values are real; tests that need fixed values still mock expo-crypto themselves, which takes precedence. +import { createHash, randomBytes, randomUUID as nodeRandomUUID } from 'crypto'; + +export const CryptoDigestAlgorithm = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA384: 'SHA-384', SHA512: 'SHA-512', MD5: 'MD5' } as const; +export const CryptoEncoding = { HEX: 'hex', BASE64: 'base64' } as const; + +const nodeAlgorithm = (algorithm: string) => algorithm.replace('-', '').toLowerCase(); + +export const getRandomBytes = (byteCount: number): Uint8Array => new Uint8Array(randomBytes(byteCount)); +export const getRandomBytesAsync = async (byteCount: number): Promise => getRandomBytes(byteCount); +export const randomUUID = (): string => nodeRandomUUID(); +export const digestStringAsync = async (algorithm: string, data: string, options?: { encoding?: string }): Promise => + createHash(nodeAlgorithm(algorithm)) + .update(data) + .digest(options?.encoding === 'base64' ? 'base64' : 'hex'); +export const digest = async (algorithm: string, data: ArrayBuffer | Uint8Array): Promise => { + // A copy into a fresh ArrayBuffer: Node's pooled Buffer may sit on a shared backing store. + return new Uint8Array(createHash(nodeAlgorithm(algorithm)).update(Buffer.from(data as ArrayBuffer)).digest()).buffer; +}; +export const aesEncryptAsync = jest.fn(); +export const aesDecryptAsync = jest.fn(); +export class AESEncryptionKey {} +export class AESSealedData {} diff --git a/__mocks__/react-native-passkey.ts b/__mocks__/react-native-passkey.ts new file mode 100644 index 00000000..8f02cad8 --- /dev/null +++ b/__mocks__/react-native-passkey.ts @@ -0,0 +1,8 @@ +// Jest stand-in for react-native-passkey (a native module). Tests set the results they need on these mocks. +export const Passkey = { + isSupported: jest.fn(() => true), + get: jest.fn(), + create: jest.fn(), + getPlatformKey: jest.fn(), + createPlatformKey: jest.fn(), +}; diff --git a/app.config.ts b/app.config.ts index 819ca559..493d3411 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,6 +52,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ bundleIdentifier: Env.BUNDLE_ID, ...(Env.IOS_APPLE_TEAM_ID ? { appleTeamId: Env.IOS_APPLE_TEAM_ID } : {}), requireFullScreen: true, + // Passkeys: Responder's own relying-party hosts, one per region (passkey workbook section 5.1). Each host serves an + // apple-app-site-association listing only this app. Staging and development builds use their own hosts, not these. + ...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && { + associatedDomains: ['webcredentials:responder.resgrid.com', 'webcredentials:responder-eu-central.resgrid.com'], + }), infoPlist: { UIBackgroundModes: ['remote-notification', 'audio', 'bluetooth-central', 'voip'], ITSAppUsesNonExemptEncryption: false, diff --git a/package.json b/package.json index 931f8f50..56289e65 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "react-native-keyboard-controller": "1.21.9", "react-native-logs": "~5.3.0", "react-native-mmkv": "3.3.3", + "react-native-passkey": "3.6.2", "react-native-permissions": "^5.4.1", "react-native-reanimated": "4.5.1", "react-native-restart": "0.0.27", diff --git a/src/api/common/__tests__/client-token-refresh.test.ts b/src/api/common/__tests__/client-token-refresh.test.ts index fda0665b..9b7af1ab 100644 --- a/src/api/common/__tests__/client-token-refresh.test.ts +++ b/src/api/common/__tests__/client-token-refresh.test.ts @@ -409,6 +409,27 @@ describe('API Client - Token Refresh', () => { }); }); + describe('Response Interceptor - application refusals', () => { + it('passes a 401 problem refusal straight through: no refresh, no replay of the code', async () => { + useAuthStore.setState({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + accessTokenObtainedAt: Date.now() - 1000, + refreshTokenObtainedAt: Date.now() - 1000, + status: 'signedIn', + profile: { sub: 'test-user' } as any, + userId: 'test-user', + }); + mockAxios.onPost('/Mfa/VerifyStepUp').reply(401, { type: 'invalid_totp', title: 'The code is invalid', status: 401 }); + + await expect(api.post('/Mfa/VerifyStepUp', { Code: '000000' })).rejects.toMatchObject({ response: { status: 401, data: { type: 'invalid_totp' } } }); + + expect(mockedRefreshTokenRequest).not.toHaveBeenCalled(); + expect(mockAxios.history.post).toHaveLength(1); + expect(useAuthStore.getState().status).toBe('signedIn'); + }); + }); + describe('Token Expiry Helper Methods', () => { it('should correctly identify expiring soon tokens', () => { const now = Date.now(); diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx index d8aedcfc..1cbc24ce 100644 --- a/src/api/common/client.tsx +++ b/src/api/common/client.tsx @@ -2,6 +2,7 @@ import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestCo import { readProtectedGrantHeaders } from '@/lib/data-protection/grant-provider'; import { logger } from '@/lib/logging'; +import { CLIENT_HEADER, RESGRID_CLIENT } from '@/lib/mfa/client-app'; import { getBaseApiUrl } from '@/lib/storage/app'; import useAuthStore from '@/stores/auth/store'; @@ -15,6 +16,8 @@ const axiosInstance: AxiosInstance = axios.create({ timeout: DEFAULT_REQUEST_TIMEOUT_MS, headers: { 'Content-Type': 'application/json', + // Which app is calling (passkey plan section 10.4): passkeys, brokered SSO and approvals are bound to it. + [CLIENT_HEADER]: RESGRID_CLIENT, }, }); @@ -131,6 +134,13 @@ axiosInstance.interceptors.response.use( return Promise.reject(error); } + // A 401 carrying a problem `type` is the application refusing this request (a wrong code, a session that ended), + // not an expired token: the authentication layer answers those with an empty body. Refreshing and replaying would + // count a wrong code twice toward the lockout. + if (error.response?.status === 401 && typeof (error.response.data as { type?: unknown } | undefined)?.type === 'string') { + return Promise.reject(error); + } + // Handle 401 errors if (error.response?.status === 401 && !(originalRequest as InternalAxiosRequestConfig & { _retry?: boolean })._retry) { const authStore = useAuthStore.getState(); diff --git a/src/api/data-protection/data-protection.ts b/src/api/data-protection/data-protection.ts index 1794dc80..4fe77094 100644 --- a/src/api/data-protection/data-protection.ts +++ b/src/api/data-protection/data-protection.ts @@ -69,3 +69,82 @@ export const verifyStepUp = async (code: string) => { const response = await api.post(`${DATA_PROTECTION}/VerifyStepUp`, { Code: code }); return response.data; }; + +// --------------------------------------------------------------------------- +// Step-up by other methods (passkey plan sections 7.8, 7.9 and 8.1): a passkey for this app, approval from the member's +// Responder, or the department's identity provider. Every method ends in the same StepUpResult grant; the server +// decides which ones this department and member may use. +// --------------------------------------------------------------------------- + +export interface StepUpMethodsData { + /** What this member has and the department accepts for protected data, in the server's order. */ + Methods: string[]; + Preferred: string | null; + EnrolledMethods: string[]; + AllowedMethods: string[]; +} + +interface PasskeyCeremony { + RequestId: string; + Options: Record; +} + +export const getStepUpMethods = async () => { + const response = await api.get<{ Data: StepUpMethodsData }>(`${DATA_PROTECTION}/StepUpMethods`); + return response.data.Data; +}; + +export const getStepUpPasskeyOptions = async () => { + const response = await api.post<{ Data: PasskeyCeremony }>(`${DATA_PROTECTION}/PasskeyOptions`, {}); + return response.data.Data; +}; + +export const verifyStepUpPasskey = async (requestId: string, credential: Record) => { + const response = await api.post(`${DATA_PROTECTION}/VerifyPasskey`, { RequestId: requestId, Credential: credential }); + return response.data; +}; + +/** Asks the member's Responder to approve access to protected data; the number is shown on this screen only. */ +export const requestStepUpApproval = async () => { + const response = await api.post<{ Data: { ApprovalRequestId: string; MatchNumber: string; ExpiresIn: number } }>('/MfaApproval/Request', { Purpose: 'adp' }); + return response.data.Data; +}; + +export const getStepUpApprovalStatus = async (approvalRequestId: string) => { + const response = await api.post<{ Data: { State: 'pending' | 'approved' | 'denied' | 'expired' | 'canceled' | 'consumed'; ExpiresAt: string | null } }>('/MfaApproval/Status', { ApprovalRequestId: approvalRequestId }); + return response.data.Data; +}; + +export const cancelStepUpApproval = async (approvalRequestId: string) => { + await api.post('/MfaApproval/Cancel', { ApprovalRequestId: approvalRequestId }); +}; + +export const completeStepUpApproval = async (approvalRequestId: string) => { + const response = await api.post(`${DATA_PROTECTION}/CompleteApproval`, { ApprovalRequestId: approvalRequestId }); + return response.data; +}; + +export interface StepUpSsoSecrets { + returnTarget: string; + state: string; + codeChallenge: string; + platform: string; +} + +/** Begins a provider step-up for protected data, bound to this session (the broker reads the app from the session). */ +export const beginStepUpSso = async (secrets: StepUpSsoSecrets) => { + const response = await api.post<{ Data: { AuthorizeUrl: string; SsoTransactionId: string; ExpiresIn: number } }>('/Sso/Begin', { + Purpose: 'adp_step_up', + ReturnTarget: secrets.returnTarget, + State: secrets.state, + CodeChallenge: secrets.codeChallenge, + CodeChallengeMethod: 'S256', + Platform: secrets.platform, + }); + return response.data.Data; +}; + +export const completeStepUpFederated = async (ssoTransactionId: string, ssoCode: string, codeVerifier: string) => { + const response = await api.post(`${DATA_PROTECTION}/CompleteFederated`, { SsoTransactionId: ssoTransactionId, SsoCode: ssoCode, CodeVerifier: codeVerifier }); + return response.data; +}; diff --git a/src/api/mfa/account-security.ts b/src/api/mfa/account-security.ts new file mode 100644 index 00000000..0b1d3c0a --- /dev/null +++ b/src/api/mfa/account-security.ts @@ -0,0 +1,171 @@ +import type { PasskeyCeremonyData, PasskeyData, SsoBeginData, SsoRedeemData } from '@/lib/mfa/types'; + +import { api } from '../common/client'; + +// --------------------------------------------------------------------------- +// The signed-in side of sign-in methods (passkey plan sections 6.5, 7.6 and 7.9): the account methods view, passkeys for +// this app, Responder approval as the approver, and the fresh verification the server asks for before any change. +// Bodies are PascalCase; results carry their payload in `Data`. +// --------------------------------------------------------------------------- + +const data = async (request: Promise<{ data: { Data: T } }>): Promise => (await request).data.Data; + +// ---- Approving another app's sign-in (Responder is the approver) ----------------------------------------------------- + +export interface PendingApprovalData { + ApprovalRequestId: string; + RequestingApp: string | null; + InstallationLabel: string | null; + Shared: boolean; + Department: string | null; + Purpose: 'login' | 'step_up' | 'adp' | 'unlock' | 'department_entry'; + Operation: string | null; + OriginRegion: string | null; + CreatedAt: string | null; + ExpiresAt: string | null; + AttemptsRemaining: number; +} + +export const getPendingApproval = () => data(api.get('/MfaApproval/Pending')); + +export const getApprovalOptions = (approvalRequestId: string) => data(api.post('/MfaApproval/Options', { ApprovalRequestId: approvalRequestId })); + +export const approveRequest = (approvalRequestId: string, matchNumber: string, requestId: string, credential: Record) => + data<{ State: string }>(api.post('/MfaApproval/Approve', { ApprovalRequestId: approvalRequestId, MatchNumber: matchNumber, RequestId: requestId, Credential: credential })); + +export const denyRequest = (approvalRequestId: string, reason: 'declined' | 'not_me') => data<{ State: string }>(api.post('/MfaApproval/Deny', { ApprovalRequestId: approvalRequestId, Reason: reason })); + +// ---- Account methods view (plan section 6.5) -------------------------------------------------------------------------- + +export interface TotpMethodData { + Enrolled: boolean; + EnrolledOn: string | null; + LastUsedOn: string | null; + RecoveryCodesRemaining: number; + RecoveryCodeWarning: boolean; + SetUpOnSharedInstallation: boolean; + CanTurnOff: boolean; +} + +export interface PasskeyGroupData { + Client: string; + RegistrationAvailable: boolean; + Passkeys: PasskeyData[]; +} + +export interface ApprovalInstallationData { + InstallationId: string; + Label: string | null; + Platform: string | null; + IsCurrent: boolean; + ApprovalsOn: boolean; + StoppedOn: string | null; + LastDecisionOn: string | null; + LastDecision: 'approved' | 'denied' | null; +} + +export interface LinkedIdentityData { + DepartmentId: number; + DepartmentName: string | null; + ProviderType: string | null; + LinkedOn: string | null; + AcceptsProviderStepUp: boolean; + LastProviderStepUpOn: string | null; +} + +export interface MfaActivityData { + ActivityId: string; + OccurredOn: string; + Method: string; + Purpose: string; + Successful: boolean; + Client: string | null; + Installation: string | null; + SharedInstallation: boolean; + IsCurrentSession: boolean; + ReportedOn: string | null; +} + +export interface AccountMethodsData { + CurrentClient: string | null; + Totp: TotpMethodData; + PasskeyGroups: PasskeyGroupData[]; + ApprovalInstallations: ApprovalInstallationData[]; + LinkedIdentities: LinkedIdentityData[]; + RecentActivity: MfaActivityData[]; +} + +export const getAccountMethods = () => data(api.get('/AccountSecurity/Methods')); + +/** "This wasn't me": ends the session a verification served and sends a notice. */ +export const reportActivity = (activityId: string) => data<{ SessionEnded: boolean; NextSteps: string[] }>(api.post('/AccountSecurity/ReportActivity', { ActivityId: activityId })); + +// ---- Fresh proof before a change (plan sections 6.2 and 7.6 row 14) ---------------------------------------------------- + +export const reauthenticateWithPassword = (password: string) => data<{ VerifiedAt: string }>(api.post('/AccountSecurity/Reauthenticate', { Password: password })); + +export interface SsoReauthSecrets { + returnTarget: string; + state: string; + codeChallenge: string; + platform: string; +} + +/** For a member whose department signs in through its identity provider: reauthenticate there instead of with a password. */ +export const beginSsoReauthentication = (secrets: SsoReauthSecrets) => + data( + api.post('/Sso/Begin', { + Purpose: 'reauth', + ReturnTarget: secrets.returnTarget, + State: secrets.state, + CodeChallenge: secrets.codeChallenge, + CodeChallengeMethod: 'S256', + Platform: secrets.platform, + }) + ); + +export const redeemSsoReauthentication = (ssoTransactionId: string, ssoCode: string, codeVerifier: string) => + data(api.post('/Sso/Redeem', { SsoTransactionId: ssoTransactionId, SsoCode: ssoCode, CodeVerifier: codeVerifier })); + +export interface StepUpOptionsData { + Methods: string[]; + Preferred: string | null; + EnrollmentRequired: boolean; + WindowMinutes: number; + Passkey: PasskeyCeremonyData | null; +} + +export const getStepUpOptions = (operation: string) => data(api.get('/Mfa/StepUpOptions', { params: { operation } })); + +export interface VerifyStepUpInput { + Operation: string; + Method: 'totp' | 'passkey'; + Code?: string; + RequestId?: string; + Credential?: Record; +} + +export const verifyStepUp = (input: VerifyStepUpInput) => data<{ VerifiedAt: string; ExpiresAt: string }>(api.post('/Mfa/VerifyStepUp', input as unknown as Record)); + +// ---- Passkeys for this app (plan section 6.1) -------------------------------------------------------------------------- + +export const getPasskeyRegistrationOptions = () => data(api.post('/Passkeys/RegistrationOptions', {})); + +export const completePasskeyRegistration = (requestId: string, credential: Record, displayName: string) => + data(api.post('/Passkeys/CompleteRegistration', { RequestId: requestId, Credential: credential, DisplayName: displayName })); + +export interface PasskeyChangeData { + Revoked: number; + SessionsEnded: number; + CurrentSessionEnded: boolean; +} + +export const renamePasskey = (passkeyId: string, displayName: string) => data(api.post('/Passkeys/Rename', { PasskeyId: passkeyId, DisplayName: displayName })); + +export const revokePasskey = (passkeyId: string) => data(api.post('/Passkeys/Revoke', { PasskeyId: passkeyId })); + +/** Responder passkeys only: whether this passkey may approve other apps' sign-ins. */ +export const setPasskeyApproval = (passkeyId: string, enabled: boolean) => data(api.post('/Passkeys/SetApproval', { PasskeyId: passkeyId, Enabled: enabled })); + +export const stopApprovalInstallations = (installationId: string | null) => + data<{ InstallationsStopped: number; PasskeysStopped: number }>(api.post('/MfaApproval/Installations/Disable', installationId ? { InstallationId: installationId, All: false } : { All: true })); diff --git a/src/app/(app)/__tests__/account-security.test.tsx b/src/app/(app)/__tests__/account-security.test.tsx new file mode 100644 index 00000000..8574b09a --- /dev/null +++ b/src/app/(app)/__tests__/account-security.test.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import * as api from '@/api/mfa/account-security'; +import { createPasskeyCredential } from '@/lib/mfa/passkey'; + +import AccountSecurity from '../account-security'; + +jest.mock('expo-router', () => ({ Stack: { Screen: () => null } })); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/api/mfa/account-security'); +jest.mock('@/lib/mfa/passkey', () => ({ createPasskeyCredential: jest.fn(), getPasskeyAssertion: jest.fn(), passkeysSupported: jest.fn(() => true) })); +jest.mock('@/lib/mfa/sso-browser', () => ({ runSsoRoundTrip: jest.fn() })); +jest.mock('@/components/ui/modal', () => { + const { View } = require('react-native'); + return { + Modal: ({ isOpen, children, ...props }: any) => (isOpen ? {children} : null), + ModalBackdrop: () => null, + ModalBody: ({ children }: any) => {children}, + ModalContent: ({ children }: any) => {children}, + ModalFooter: ({ children }: any) => {children}, + ModalHeader: ({ children }: any) => {children}, + }; +}); + +const mocked = api as jest.Mocked; +const refusal = (status: number, type: string) => Object.assign(new Error(type), { response: { status, data: { type } } }); +const methods: api.AccountMethodsData = { + CurrentClient: 'responder', + Totp: { Enrolled: true, EnrolledOn: '2026-09-01T00:00:00Z', LastUsedOn: null, RecoveryCodesRemaining: 8, RecoveryCodeWarning: false, SetUpOnSharedInstallation: false, CanTurnOff: true }, + PasskeyGroups: [ + { Client: 'responder', RegistrationAvailable: true, Passkeys: [] }, + { Client: 'web', RegistrationAvailable: false, Passkeys: [{ PasskeyId: 'pk-web', DisplayName: 'Laptop' } as never] }, + ], + ApprovalInstallations: [], + LinkedIdentities: [], + RecentActivity: [{ ActivityId: 'act-1', OccurredOn: '2026-09-29T10:00:00Z', Method: 'totp', Purpose: 'login', Successful: true, Client: 'web', Installation: null, SharedInstallation: false, IsCurrentSession: false, ReportedOn: null }], +}; + +describe('AccountSecurity', () => { + beforeEach(() => { + jest.clearAllMocks(); + mocked.getAccountMethods.mockResolvedValue(methods); + }); + + it('adds a passkey after the proof the server asks for, one step at a time', async () => { + mocked.getPasskeyRegistrationOptions + .mockRejectedValueOnce(refusal(401, 'reauthentication_required')) + .mockRejectedValueOnce(refusal(401, 'step_up_required')) + .mockResolvedValue({ RequestId: 'req-1', Options: { challenge: 'abc' } }); + mocked.reauthenticateWithPassword.mockResolvedValue({ VerifiedAt: 'now' }); + mocked.getStepUpOptions.mockResolvedValue({ Methods: ['totp'], Preferred: 'totp', EnrollmentRequired: false, WindowMinutes: 5, Passkey: null }); + mocked.verifyStepUp.mockResolvedValue({ VerifiedAt: 'now', ExpiresAt: 'later' }); + (createPasskeyCredential as jest.Mock).mockResolvedValue({ id: 'new' }); + mocked.completePasskeyRegistration.mockResolvedValue({ PasskeyId: 'pk-new' } as never); + + const { getByTestId, queryByTestId } = render(); + await waitFor(() => expect(getByTestId('account-add-passkey')).toBeTruthy()); + expect(getByTestId('account-passkeys-web')).toBeTruthy(); + + await act(async () => fireEvent.press(getByTestId('account-add-passkey'))); + await waitFor(() => expect(getByTestId('account-verify-secret')).toBeTruthy()); + fireEvent.changeText(getByTestId('account-verify-secret'), 'pw'); + await act(async () => fireEvent.press(getByTestId('account-verify-submit'))); + expect(mocked.reauthenticateWithPassword).toHaveBeenCalledWith('pw'); + + await waitFor(() => expect(mocked.getStepUpOptions).toHaveBeenCalledWith('account_security')); + fireEvent.changeText(getByTestId('account-verify-secret'), '123456'); + await act(async () => fireEvent.press(getByTestId('account-verify-submit'))); + expect(mocked.verifyStepUp).toHaveBeenCalledWith({ Operation: 'account_security', Method: 'totp', Code: '123456' }); + + await waitFor(() => expect(mocked.completePasskeyRegistration).toHaveBeenCalledWith('req-1', { id: 'new' }, '')); + await waitFor(() => expect(queryByTestId('account-verify-modal')).toBeNull()); + expect(getByTestId('account-notice')).toBeTruthy(); + }); + + it('stops when the member declines the proof', async () => { + mocked.getPasskeyRegistrationOptions.mockRejectedValue(refusal(401, 'reauthentication_required')); + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('account-add-passkey')).toBeTruthy()); + await act(async () => fireEvent.press(getByTestId('account-add-passkey'))); + await act(async () => fireEvent.press(getByTestId('account-verify-cancel'))); + expect(mocked.getPasskeyRegistrationOptions).toHaveBeenCalledTimes(1); + expect(mocked.completePasskeyRegistration).not.toHaveBeenCalled(); + }); + + it('reports the refusal instead of asking for proof a third time when no retry is left', async () => { + mocked.getPasskeyRegistrationOptions.mockRejectedValue(refusal(401, 'reauthentication_required')); + mocked.reauthenticateWithPassword.mockResolvedValue({ VerifiedAt: 'now' }); + const { getByTestId, queryByTestId } = render(); + await waitFor(() => expect(getByTestId('account-add-passkey')).toBeTruthy()); + + await act(async () => fireEvent.press(getByTestId('account-add-passkey'))); + for (let proof = 0; proof < 2; proof++) { + await waitFor(() => expect(getByTestId('account-verify-secret')).toBeTruthy()); + fireEvent.changeText(getByTestId('account-verify-secret'), 'pw'); + await act(async () => fireEvent.press(getByTestId('account-verify-submit'))); + } + + await waitFor(() => expect(getByTestId('account-error').props.children).toBe('mfa.errors.reauthentication_required')); + expect(queryByTestId('account-verify-modal')).toBeNull(); + expect(mocked.reauthenticateWithPassword).toHaveBeenCalledTimes(2); + expect(mocked.getPasskeyRegistrationOptions).toHaveBeenCalledTimes(3); + }); + + it('reports a verification that was not the member', async () => { + mocked.reportActivity.mockResolvedValue({ SessionEnded: true, NextSteps: [] }); + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('activity-report-act-1')).toBeTruthy()); + await act(async () => fireEvent.press(getByTestId('activity-report-act-1'))); + expect(mocked.reportActivity).toHaveBeenCalledWith('act-1'); + }); +}); diff --git a/src/app/(app)/__tests__/approve-sign-in.test.tsx b/src/app/(app)/__tests__/approve-sign-in.test.tsx new file mode 100644 index 00000000..cf4cbe1e --- /dev/null +++ b/src/app/(app)/__tests__/approve-sign-in.test.tsx @@ -0,0 +1,128 @@ +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import * as api from '@/api/mfa/account-security'; +import { getPasskeyAssertion } from '@/lib/mfa/passkey'; +import { PasskeyCeremonyError } from '@/lib/mfa/passkey-errors'; + +import ApproveSignIn from '../approve-sign-in'; + +const mockBack = jest.fn(); +jest.mock('expo-router', () => ({ + Stack: { Screen: () => null }, + useLocalSearchParams: () => ({ id: 'ap-1' }), + useRouter: () => ({ canGoBack: () => true, back: mockBack, replace: jest.fn() }), +})); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => (options?.count !== undefined ? `${key}:${options.count}` : options?.label !== undefined ? `${key}:${options.label}` : key), + }), +})); +jest.mock('@/api/mfa/account-security'); +jest.mock('@/lib/mfa/passkey', () => ({ getPasskeyAssertion: jest.fn() })); + +const mocked = api as jest.Mocked; +const pending: api.PendingApprovalData = { + ApprovalRequestId: 'ap-1', + RequestingApp: 'unit', + InstallationLabel: 'Engine 7 tablet', + Shared: true, + Department: 'Station 1', + Purpose: 'unlock', + Operation: null, + OriginRegion: 'Colorado, US', + CreatedAt: '2026-09-29T12:00:00', + ExpiresAt: '2026-09-29T12:02:00', + AttemptsRemaining: 3, +}; +const refusal = (status: number, type: string) => Object.assign(new Error(type), { response: { status, data: { type } } }); + +describe('ApproveSignIn', () => { + beforeEach(() => { + jest.clearAllMocks(); + mocked.getPendingApproval.mockResolvedValue(pending); + }); + + it('shows what is asking, then approves with the number and this app\'s passkey', async () => { + mocked.getApprovalOptions.mockResolvedValue({ RequestId: 'req-1', Options: { challenge: 'abc' } }); + (getPasskeyAssertion as jest.Mock).mockResolvedValue({ id: 'cred' }); + mocked.approveRequest.mockResolvedValue({ State: 'approved' }); + + const { getByTestId, getByText } = render(); + await waitFor(() => expect(getByTestId('approve-details')).toBeTruthy()); + expect(getByText('mfa.approve.purpose.unlock')).toBeTruthy(); + expect(getByText('mfa.approve.shared')).toBeTruthy(); + + fireEvent.changeText(getByTestId('approve-number'), '42'); + await act(async () => fireEvent.press(getByTestId('approve-confirm'))); + + expect(mocked.approveRequest).toHaveBeenCalledWith('ap-1', '42', 'req-1', { id: 'cred' }); + expect(getByTestId('approve-approved')).toBeTruthy(); + fireEvent.press(getByTestId('approve-done')); + expect(mockBack).toHaveBeenCalled(); + }); + + it("shows a shared workstation's sign-in as shared and named by the station, and a personal one by its device alone", async () => { + mocked.getPendingApproval.mockResolvedValueOnce({ ...pending, Purpose: 'login', RequestingApp: 'dispatch', InstallationLabel: 'Dispatch desk 2', Shared: true }); + const shared = render(); + await waitFor(() => expect(shared.getByTestId('approve-details')).toBeTruthy()); + expect(shared.getByText('mfa.approve.purpose.login')).toBeTruthy(); + expect(shared.getByText('mfa.approve.installation:Dispatch desk 2')).toBeTruthy(); + expect(shared.getByText('mfa.approve.shared')).toBeTruthy(); + shared.unmount(); + + mocked.getPendingApproval.mockResolvedValueOnce({ ...pending, Purpose: 'login', InstallationLabel: "Pat's iPhone", Shared: false }); + const personal = render(); + await waitFor(() => expect(personal.getByTestId('approve-details')).toBeTruthy()); + expect(personal.getByText("mfa.approve.installation:Pat's iPhone")).toBeTruthy(); + expect(personal.queryByText('mfa.approve.shared')).toBeNull(); + }); + + it('says how many tries are left after a wrong number, and never approves without one', async () => { + mocked.getApprovalOptions.mockResolvedValue({ RequestId: 'req-1', Options: {} }); + (getPasskeyAssertion as jest.Mock).mockResolvedValue({ id: 'cred' }); + mocked.approveRequest.mockRejectedValueOnce(refusal(400, 'approval_number_mismatch')); + mocked.getPendingApproval.mockResolvedValueOnce(pending).mockResolvedValueOnce({ ...pending, AttemptsRemaining: 2 }); + + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('approve-number')).toBeTruthy()); + expect(getByTestId('approve-confirm').props.accessibilityState?.disabled ?? getByTestId('approve-confirm').props.isDisabled).toBeTruthy(); + + fireEvent.changeText(getByTestId('approve-number'), '17'); + await act(async () => fireEvent.press(getByTestId('approve-confirm'))); + await waitFor(() => expect(getByTestId('approve-error').props.children).toBe('mfa.approve.number_mismatch:2')); + }); + + it('keeps the request when the passkey prompt is closed', async () => { + mocked.getApprovalOptions.mockResolvedValue({ RequestId: 'req-1', Options: {} }); + (getPasskeyAssertion as jest.Mock).mockRejectedValue(new PasskeyCeremonyError('cancelled')); + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('approve-number')).toBeTruthy()); + fireEvent.changeText(getByTestId('approve-number'), '42'); + await act(async () => fireEvent.press(getByTestId('approve-confirm'))); + expect(mocked.approveRequest).not.toHaveBeenCalled(); + expect(getByTestId('approve-error').props.children).toBe('mfa.errors.passkey_cancelled'); + expect(getByTestId('approve-details')).toBeTruthy(); + }); + + it('denies, or reports that it was not the member', async () => { + mocked.denyRequest.mockResolvedValue({ State: 'denied' }); + const first = render(); + await waitFor(() => expect(first.getByTestId('approve-deny')).toBeTruthy()); + await act(async () => fireEvent.press(first.getByTestId('approve-deny'))); + expect(mocked.denyRequest).toHaveBeenCalledWith('ap-1', 'declined'); + expect(first.getByTestId('approve-denied')).toBeTruthy(); + first.unmount(); + + const second = render(); + await waitFor(() => expect(second.getByTestId('approve-not-me')).toBeTruthy()); + await act(async () => fireEvent.press(second.getByTestId('approve-not-me'))); + expect(mocked.denyRequest).toHaveBeenLastCalledWith('ap-1', 'not_me'); + }); + + it('says so when nothing is waiting', async () => { + mocked.getPendingApproval.mockResolvedValue(null); + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('approve-none')).toBeTruthy()); + }); +}); diff --git a/src/app/(app)/__tests__/certification-edit.test.tsx b/src/app/(app)/__tests__/certification-edit.test.tsx index 91175509..cedce816 100644 --- a/src/app/(app)/__tests__/certification-edit.test.tsx +++ b/src/app/(app)/__tests__/certification-edit.test.tsx @@ -123,3 +123,19 @@ describe('EditCertificationScreen', () => { expect(getCertification).not.toHaveBeenCalled(); }); }); + +it('saves issued and expiry dates selected from pickers', async () => { + mockParams.id = '42'; + useCertificationsStore.setState({ items: [record()] }); + jest.mocked(saveCertification).mockResolvedValue(record()); + const screen = render(); + fireEvent.press(screen.getByTestId('certification-received')); + fireEvent.press(screen.getByTestId('certification-received-day-15')); + fireEvent.press(screen.getByTestId('certification-received-done')); + fireEvent.press(screen.getByTestId('certification-expires')); + fireEvent.press(screen.getByTestId('certification-expires-day-20')); + fireEvent.press(screen.getByTestId('certification-expires-done')); + fireEvent.press(screen.getByTestId('certification-save')); + await waitFor(() => expect(saveCertification).toHaveBeenCalledWith(expect.objectContaining({ ReceivedOn: '2025-01-15', ExpiresOn: '2027-01-20' }))); + screen.unmount(); +}); diff --git a/src/app/(app)/__tests__/map.test.tsx b/src/app/(app)/__tests__/map.test.tsx index a794cbe7..4a24a4cd 100644 --- a/src/app/(app)/__tests__/map.test.tsx +++ b/src/app/(app)/__tests__/map.test.tsx @@ -4,6 +4,9 @@ import React from 'react'; import { useAnalytics } from '@/hooks/use-analytics'; import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; +import { FALLBACK_DAY_MAP_STYLE } from '@/lib/map-style'; +import { GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; +import { useCoreStore } from '@/stores/app/core-store'; import HomeMap from '../map'; @@ -116,13 +119,13 @@ jest.mock('@rnmapbox/maps', () => ({ __esModule: true, default: { setAccessToken: jest.fn(), - StyleURL: { - Street: 'mapbox://styles/mapbox/streets-v11', - Satellite: 'mapbox://styles/mapbox/satellite-v9', - }, - MapView: ({ children, testID }: any) => { + MapView: ({ children, testID, styleURL }: any) => { const { View } = require('react-native'); - return {children}; + return ( + + {children} + + ); }, Camera: ({ children }: any) => { const { View } = require('react-native'); @@ -317,6 +320,13 @@ jest.mock('@/stores/calls/active-call-store', () => ({ }, })); +// A real store, so the base map style follows department config when it lands after mount. +jest.mock('@/stores/app/core-store', () => { + const { create } = require('zustand'); + + return { useCoreStore: create(() => ({ config: null })) }; +}); + jest.mock('@/stores/app/location-store', () => { let mockState = { latitude: 40.7128, @@ -615,6 +625,36 @@ describe('HomeMap', () => { expect(screen.getByTestId('home-map-container')).toBeTruthy(); }); + describe('department map style', () => { + afterEach(() => { + useCoreStore.setState({ config: null }); + }); + + it('renders the department day style once config lands', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('map-pins')).toBeTruthy(); + }); + + // Before config arrives the map uses the bootstrap fallback, not a hardcoded SDK style. + expect(screen.getByTestId('home-map-view').props.styleURL).toBe(FALLBACK_DAY_MAP_STYLE); + + act(() => { + useCoreStore.setState({ + config: Object.assign(new GetConfigResultData(), { + MapDayStyleUrl: 'mapbox://styles/mapbox/satellite-v9', + MapNightStyleUrl: 'mapbox://styles/mapbox/navigation-night-v1', + }), + }); + }); + + await waitFor(() => { + expect(screen.getByTestId('home-map-view').props.styleURL).toBe('mapbox://styles/mapbox/satellite-v9'); + }); + }); + }); + describe('Analytics Tracking', () => { it('tracks map view on focus', async () => { const mockLocationStore = jest.requireMock('@/stores/app/location-store') as any; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 4844697c..edb562ab 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -11,6 +11,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { RealtimeStatusBanner } from '@/components/common/realtime-status-banner'; import { StepUpPromptHost } from '@/components/data-protection/step-up-prompt-host'; +import { RecoveryCodesModal } from '@/components/mfa/recovery-codes-modal'; import { NotificationButton } from '@/components/notifications/NotificationButton'; import { NotificationInbox } from '@/components/notifications/NotificationInbox'; import SideMenu from '@/components/sidebar/side-menu-content'; @@ -22,10 +23,11 @@ import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { useAppInitRetry } from '@/hooks/use-app-init-retry'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; +import { usePendingApprovalCheck } from '@/hooks/use-pending-approval-check'; import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle'; import { useAuthStore } from '@/lib/auth'; -import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; +import { onMapboxAccessTokenChange } from '@/lib/mapbox-token'; import { useIsFirstTime } from '@/lib/storage'; import { loadRealtimeGeolocationState } from '@/lib/storage/realtime-geolocation'; import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; @@ -46,13 +48,20 @@ import { securityStore } from '@/stores/security/store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/weather-alerts-store'; -Mapbox.setAccessToken(Env.RESPOND_MAPBOX_PUBKEY); +// Sets the SDK token now (stored server token, else the built-in one) and again whenever it changes. +// Store listeners run synchronously, before React re-renders, so the SDK has the new token before a +// map re-renders with a style that needs it. On web, Mapbox.setAccessToken sets mapboxgl.accessToken. +onMapboxAccessTokenChange((token) => { + Mapbox.setAccessToken(token); +}); export default function TabLayout() { const { t } = useTranslation(); const router = useRouter(); const pathname = usePathname(); const status = useAuthStore((state) => state.status); + // Approve with Responder: open a waiting request when the app comes to the foreground (the push may be late). + usePendingApprovalCheck(status === 'signedIn'); const [isFirstTime, _setIsFirstTime] = useIsFirstTime(); const [isOpen, setIsOpen] = React.useState(false); const [isNotificationsOpen, setIsNotificationsOpen] = React.useState(false); @@ -404,6 +413,7 @@ export default function TabLayout() { two prompts over each other. */} + {/* Top Navigation Bar */} diff --git a/src/app/(app)/account-security.tsx b/src/app/(app)/account-security.tsx new file mode 100644 index 00000000..445e43d8 --- /dev/null +++ b/src/app/(app)/account-security.tsx @@ -0,0 +1,310 @@ +import { Stack } from 'expo-router'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, ScrollView } from 'react-native'; + +import { + type AccountMethodsData, + completePasskeyRegistration, + getAccountMethods, + getPasskeyRegistrationOptions, + renamePasskey, + reportActivity, + revokePasskey, + setPasskeyApproval, + stopApprovalInstallations, +} from '@/api/mfa/account-security'; +import { type AccountProof, AccountVerifyModal } from '@/components/mfa/account-verify-modal'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Spinner } from '@/components/ui/spinner'; +import { Switch } from '@/components/ui/switch'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { RESGRID_CLIENT } from '@/lib/mfa/client-app'; +import { toMfaProblem } from '@/lib/mfa/errors'; +import { mfaErrorKey } from '@/lib/mfa/messages'; +import { createPasskeyCredential, passkeysSupported } from '@/lib/mfa/passkey'; +import { isPasskeyCeremonyError } from '@/lib/mfa/passkey-errors'; +import { parseUtc, type PasskeyData } from '@/lib/mfa/types'; +import useAuthStore from '@/stores/auth/store'; + +const APP_NAMES: Record = { web: 'Resgrid Web', responder: 'Resgrid Responder', unit: 'Resgrid Unit', dispatch: 'Resgrid Dispatch', ic: 'Resgrid Command' }; + +/** A change, and at most two retries after the proof the server asks for. */ +const MAX_ROUNDS = 3; + +const when = (value: string | null | undefined): string | null => { + const parsed = parseUtc(value); + return parsed ? new Date(parsed).toLocaleString() : null; +}; + +/** + * Sign-in and security (passkey plan section 6.5): the authenticator app and recovery codes, passkeys for this app and + * where the others are, the Responder installations that approve sign-ins, linked organization sign-in, and 30 days of + * verifications with "This wasn't me". Every change asks for the proof the server requires first. + */ +export default function AccountSecurity() { + const { t } = useTranslation(); + const [methods, setMethods] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [errorCode, setErrorCode] = useState(null); + const [notice, setNotice] = useState(null); + const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null); + const [proof, setProof] = useState(null); + const proofResult = useRef<((verified: boolean) => void) | null>(null); + + const load = useCallback(async () => { + setLoading(true); + try { + setMethods(await getAccountMethods()); + setErrorCode(null); + } catch (error) { + setErrorCode(toMfaProblem(error).code); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + /** Asks for the proof the server named, and resolves whether the member gave it. */ + const prove = useCallback( + (kind: AccountProof) => + new Promise((resolve) => { + proofResult.current = resolve; + setProof(kind); + }), + [] + ); + + const onProofDone = useCallback((verified: boolean) => { + setProof(null); + proofResult.current?.(verified); + proofResult.current = null; + }, []); + + /** Runs a change; when the server asks for fresh proof first, asks for it and tries again (at most twice). */ + const change = useCallback( + async (action: () => Promise, done?: string) => { + setBusy(true); + setErrorCode(null); + setNotice(null); + try { + for (let round = 0; round < MAX_ROUNDS; round++) { + try { + const result = await action(); + if ((result as { CurrentSessionEnded?: boolean } | undefined)?.CurrentSessionEnded) { + await useAuthStore.getState().logout('Sign-in methods changed'); + return; + } + if (done) { + setNotice(done); + } + await load(); + return; + } catch (error) { + if (isPasskeyCeremonyError(error)) { + setErrorCode(`passkey_${error.reason}`); + return; + } + const code = toMfaProblem(error).code; + const needed: AccountProof | null = code === 'reauthentication_required' ? 'reauthenticate' : code === 'step_up_required' ? 'step_up' : null; + // No retry is left after the last round, so asking for proof again would verify the member for nothing. + if (!needed || round === MAX_ROUNDS - 1) { + setErrorCode(code); + return; + } + if (!(await prove(needed))) { + return; + } + } + } + } finally { + setBusy(false); + } + }, + [load, prove] + ); + + const addPasskey = () => + void change(async () => { + const ceremony = await getPasskeyRegistrationOptions(); + const credential = await createPasskeyCredential(ceremony.Options); + return completePasskeyRegistration(ceremony.RequestId, credential, ''); + }, t('mfa.account.passkey_added')); + + const ownGroup = methods?.PasskeyGroups.find((g) => g.Client === (methods.CurrentClient ?? RESGRID_CLIENT)); + const otherGroups = (methods?.PasskeyGroups ?? []).filter((g) => g !== ownGroup && g.Passkeys.length > 0); + + const renderPasskey = (passkey: PasskeyData) => ( + + + {passkey.DisplayName} + {t('mfa.account.passkey_created', { time: when(passkey.CreatedOn) ?? '' })} + {passkey.LastUsedOn ? {t('mfa.account.passkey_last_used', { time: when(passkey.LastUsedOn) ?? '' })} : null} + {passkey.ApprovalEnabled !== null ? ( + + void change(() => setPasskeyApproval(passkey.PasskeyId, enabled))} isDisabled={busy} testID={`passkey-approval-${passkey.PasskeyId}`} /> + {t('mfa.account.passkey_approves')} + + ) : null} + + + + + + + ); + + return ( + <> + + void load()} />}> + + {notice ? ( + + {notice} + + ) : null} + {errorCode ? ( + + {t(mfaErrorKey(errorCode))} + + ) : null} + + {methods ? ( + <> + + {t('mfa.account.authenticator')} + {methods.Totp.Enrolled ? t('mfa.account.authenticator_on', { time: when(methods.Totp.EnrolledOn) ?? '' }) : t('mfa.account.authenticator_off')} + {methods.Totp.Enrolled ? ( + + {t('mfa.account.recovery_codes_left', { count: methods.Totp.RecoveryCodesRemaining })} + + ) : null} + + + + {t('mfa.account.passkeys_here')} + {ownGroup && ownGroup.Passkeys.length > 0 ? ownGroup.Passkeys.map(renderPasskey) : {t('mfa.account.no_passkeys')}} + {ownGroup?.RegistrationAvailable && passkeysSupported() ? ( + + ) : null} + {otherGroups.map((group) => ( + + {t('mfa.account.passkeys_elsewhere', { app: APP_NAMES[group.Client] ?? group.Client, count: group.Passkeys.length })} + + ))} + + + {methods.ApprovalInstallations.length > 0 ? ( + + {t('mfa.account.approvals')} + {methods.ApprovalInstallations.map((installation) => ( + + + + {installation.Label ?? installation.Platform ?? t('mfa.account.this_device')} + {installation.IsCurrent ? ` · ${t('mfa.account.this_device')}` : ''} + + {installation.ApprovalsOn ? t('mfa.account.approvals_on') : t('mfa.account.approvals_off')} + + {installation.ApprovalsOn ? ( + + ) : null} + + ))} + + ) : null} + + {methods.LinkedIdentities.length > 0 ? ( + + {t('mfa.account.linked')} + {methods.LinkedIdentities.map((link) => ( + + {link.DepartmentName ?? link.DepartmentId} + {link.AcceptsProviderStepUp ? ` · ${t('mfa.account.linked_step_up')}` : ''} + + ))} + + ) : null} + + + {t('mfa.account.activity')} + {methods.RecentActivity.length === 0 ? {t('mfa.account.no_activity')} : null} + {methods.RecentActivity.map((activity) => ( + + + + {t(`mfa.account.activity_${activity.Successful ? 'ok' : 'failed'}`, { + method: t(`mfa.methods.${activity.Method}`, activity.Method), + app: (activity.Client && APP_NAMES[activity.Client]) ?? '', + })} + + {when(activity.OccurredOn)} + + {activity.Successful && !activity.ReportedOn ? ( + + ) : null} + + ))} + + + ) : loading ? ( + + ) : null} + + {renaming ? ( + + + setRenaming({ ...renaming, name })} maxLength={100} aria-label={t('mfa.account.rename')} /> + + + + + + + ) : null} + + + + + ); +} diff --git a/src/app/(app)/approve-sign-in.tsx b/src/app/(app)/approve-sign-in.tsx new file mode 100644 index 00000000..28bfe12d --- /dev/null +++ b/src/app/(app)/approve-sign-in.tsx @@ -0,0 +1,174 @@ +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { approveRequest, denyRequest, getApprovalOptions, getPendingApproval, type PendingApprovalData } from '@/api/mfa/account-security'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { Input, InputField } from '@/components/ui/input'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { toMfaProblem } from '@/lib/mfa/errors'; +import { mfaErrorKey } from '@/lib/mfa/messages'; +import { getPasskeyAssertion } from '@/lib/mfa/passkey'; +import { isPasskeyCeremonyError } from '@/lib/mfa/passkey-errors'; +import { parseUtc } from '@/lib/mfa/types'; + +type Phase = 'loading' | 'review' | 'approved' | 'denied' | 'none'; + +const APP_NAMES: Record = { web: 'Resgrid Web', unit: 'Resgrid Unit', dispatch: 'Resgrid Dispatch', ic: 'Resgrid Command' }; + +/** + * Approve with Responder, as the approver (passkey plan section 7.9). The request names the app, installation, department, + * purpose and coarse origin; the member types the number shown on the other screen and confirms with this app's + * passkey. The number is never pushed, so only someone who can see that screen can approve it. "This wasn't me" ends + * the other sign-in and pauses requests for a while. + */ +export default function ApproveSignIn() { + const { t } = useTranslation(); + const router = useRouter(); + const params = useLocalSearchParams<{ id?: string }>(); + const [phase, setPhase] = useState('loading'); + const [request, setRequest] = useState(null); + const [number, setNumber] = useState(''); + const [busy, setBusy] = useState(false); + const [errorCode, setErrorCode] = useState(null); + + const load = useCallback(async () => { + try { + const pending = await getPendingApproval(); + setRequest(pending); + setPhase(pending ? 'review' : 'none'); + } catch (error) { + setErrorCode(toMfaProblem(error).code); + setPhase('none'); + } + }, []); + + useEffect(() => { + void load(); + }, [load, params.id]); + + const approve = useCallback(async () => { + if (!request) { + return; + } + setBusy(true); + setErrorCode(null); + try { + const ceremony = await getApprovalOptions(request.ApprovalRequestId); + const credential = await getPasskeyAssertion(ceremony.Options); + await approveRequest(request.ApprovalRequestId, number.trim(), ceremony.RequestId, credential); + setPhase('approved'); + } catch (error) { + if (isPasskeyCeremonyError(error)) { + setErrorCode(`passkey_${error.reason}`); + } else { + const problem = toMfaProblem(error); + setErrorCode(problem.code); + if (problem.code === 'approval_number_mismatch') { + // The attempts left come from the server; a request that ran out is denied there. + await load(); + } else if (problem.code === 'approval_denied' || problem.code === 'approval_expired') { + setPhase('none'); + } + } + } finally { + setNumber(''); + setBusy(false); + } + }, [load, number, request]); + + const deny = useCallback( + async (reason: 'declined' | 'not_me') => { + if (!request) { + return; + } + setBusy(true); + setErrorCode(null); + try { + await denyRequest(request.ApprovalRequestId, reason); + setPhase('denied'); + } catch (error) { + setErrorCode(toMfaProblem(error).code); + } finally { + setBusy(false); + } + }, + [request] + ); + + const done = useCallback(() => (router.canGoBack() ? router.back() : router.replace('/(app)')), [router]); + + const expiresAt = parseUtc(request?.ExpiresAt); + + return ( + <> + + + + {t('mfa.approve.title')} + + {phase === 'loading' ? : null} + + {phase === 'review' && request ? ( + <> + + + {t(`mfa.approve.purpose.${request.Purpose}`)} + {t('mfa.approve.app', { app: (request.RequestingApp && APP_NAMES[request.RequestingApp]) ?? t('mfa.approve.unknown_app') })} + {request.InstallationLabel ? {t('mfa.approve.installation', { label: request.InstallationLabel })} : null} + {request.Shared ? {t('mfa.approve.shared')} : null} + {request.Department ? {t('mfa.approve.department', { department: request.Department })} : null} + {request.OriginRegion ? {t('mfa.approve.origin', { origin: request.OriginRegion })} : null} + {expiresAt ? {t('mfa.approve.expires', { time: new Date(expiresAt).toLocaleTimeString() })} : null} + + + {t('mfa.approve.number_body')} + + + + + + + + ) : null} + + {phase === 'approved' ? {t('mfa.approve.approved')} : null} + {phase === 'denied' ? {t('mfa.approve.denied')} : null} + {phase === 'none' ? {t('mfa.approve.none')} : null} + + {errorCode ? ( + + {errorCode === 'approval_number_mismatch' && request ? t('mfa.approve.number_mismatch', { count: request.AttemptsRemaining }) : t(mfaErrorKey(errorCode))} + + ) : null} + + {phase !== 'review' && phase !== 'loading' ? ( + + ) : null} + + + + ); +} diff --git a/src/app/(app)/certifications/[id].tsx b/src/app/(app)/certifications/[id].tsx index f9a257f8..7ae98299 100644 --- a/src/app/(app)/certifications/[id].tsx +++ b/src/app/(app)/certifications/[id].tsx @@ -4,6 +4,7 @@ import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, ScrollView } from 'react-native'; +import { DateTimeField } from '@/components/common/date-time-field'; import { Button, ButtonText } from '@/components/ui/button'; import { HStack } from '@/components/ui/hstack'; import { Input, InputField } from '@/components/ui/input'; @@ -145,9 +146,7 @@ export default function CertificationScreen() { {t('certifications.renew')} - - - + @@ -171,9 +170,9 @@ export default function CertificationScreen() { - - - + + + diff --git a/src/app/(app)/certifications/edit.tsx b/src/app/(app)/certifications/edit.tsx index 60682667..9445f40c 100644 --- a/src/app/(app)/certifications/edit.tsx +++ b/src/app/(app)/certifications/edit.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; import { getCertification } from '@/api/certifications/certifications'; +import { DateTimeField } from '@/components/common/date-time-field'; import { OptionSelect } from '@/components/operations/option-select'; import { Button, ButtonText } from '@/components/ui/button'; import { HStack } from '@/components/ui/hstack'; @@ -181,12 +182,8 @@ const CertificationForm: React.FC = ({ existing }) => { - - - - - - + + + + + ) : null} + + {step === 'replace' ? ( + <> + {t('mfa.recovery.replace_body')} + {key ? ( + + + {key.SharedKey} + + + + ) : ( + + )} + {passkeys.length > 0 ? ( + + + {t('mfa.recovery.remove_passkeys')} + + {passkeys.map((passkey) => ( + togglePasskey(passkey.PasskeyId)} + testID={`recovery-passkey-${passkey.PasskeyId}`} + > + + {passkey.DisplayName} + + ))} + + ) : null} + + + + + + + ) : null} + + {step === 'done' ? ( + <> + {t('mfa.recovery.done_body')} + + + + ) : null} + + {step === 'help' ? ( + <> + {t('mfa.recovery.help_body')} + + + ) : null} + + {errorCode ? ( + + {t(mfaErrorKey(errorCode))} + + ) : null} + + + + ); +} diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx index 1e7b437b..329cca74 100644 --- a/src/app/login/sso.tsx +++ b/src/app/login/sso.tsx @@ -1,9 +1,10 @@ import { Stack, useRouter } from 'expo-router'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { View } from 'react-native'; +import { Platform, View } from 'react-native'; import { SsoDepartmentForm, SsoLoginButtons } from '@/app/login/sso-section'; +import { LoginMfaSheet } from '@/components/auth/login-mfa-sheet'; import { LoginOtpModal } from '@/components/auth/login-otp-modal'; import { FocusAwareStatusBar } from '@/components/ui'; import { Button, ButtonText } from '@/components/ui/button'; @@ -28,6 +29,7 @@ export default function SsoLogin() { // screen's prompt on that status alone means retrySsoWithOtp fires with no pending SSO // exchange, which drops the user into an error state instead of a code prompt. const isSsoMfaPending = useAuthStore((s) => s.isSsoMfaPending); + const mfaChallenge = useAuthStore((s) => s.mfaChallenge); const { trackEvent } = useAnalytics(); const [ssoPhase, setSsoPhase] = useState('department'); @@ -40,13 +42,13 @@ export default function SsoLogin() { const oidc = useOidcLogin({ authority: ssoConfig?.authority ?? '', clientId: ssoConfig?.clientId ?? '', - departmentCode: username, + departmentToken: ssoConfig?.departmentToken ?? '', }); - // SAML hook + // SAML without the broker starts on the server's page (discovery's SamlLoginUrl), which sends the browser on to the IdP. const saml = useSamlLogin({ - idpSsoUrl: ssoConfig?.metadataUrl ?? ssoConfig?.authority ?? '', - departmentCode: username, + signInUrl: ssoConfig?.samlLoginUrl ?? '', + departmentToken: ssoConfig?.departmentToken ?? '', }); // Redirect to app on successful auth @@ -117,7 +119,18 @@ export default function SsoLogin() { setSsoPhase('department'); }, []); + // Brokered SSO (passkey plan section 7.7.2): the Resgrid broker talks to the provider, and only a one-time code comes + // back to this app. The app-side OIDC and SAML flows below remain for servers without the broker. + const startBrokeredSso = useCallback(async () => { + trackEvent('sso_brokered_started', { timestamp: new Date().toISOString() }); + await useAuthStore.getState().loginWithBrokeredSso(ssoConfig?.departmentToken ? { departmentToken: ssoConfig.departmentToken } : { username }); + }, [ssoConfig, trackEvent, username]); + const handleOidcPress = useCallback(() => { + if (ssoConfig?.brokeredSsoAvailable) { + void startBrokeredSso(); + return; + } const authorityUrl = ssoConfig?.authority ?? ''; if (!isValidSsoUrl(authorityUrl)) { logger.error({ message: 'SSO: refusing OIDC login with invalid or insecure authority URL' }); @@ -125,17 +138,22 @@ export default function SsoLogin() { return; } void oidc.promptAsync(); - }, [oidc, ssoConfig]); + }, [oidc, ssoConfig, startBrokeredSso]); const handleSamlPress = useCallback(() => { - const samlUrl = ssoConfig?.metadataUrl ?? ssoConfig?.authority ?? ''; - if (!isValidSsoUrl(samlUrl)) { - logger.error({ message: 'SSO: refusing SAML login with invalid or insecure IdP URL' }); + if (ssoConfig?.brokeredSsoAvailable) { + void startBrokeredSso(); + return; + } + // Without the broker, SAML returns through this app's own scheme, which only the native app receives, and it needs the + // server's start page (an older server names none). The relay's callback is handled by the root layout. + if (Platform.OS === 'web' || !ssoConfig?.samlLoginUrl) { + logger.error({ message: 'SSO: SAML sign-in cannot start here without brokered sign-in' }); setIsErrorModalVisible(true); return; } void saml.startSamlLogin(); - }, [saml, ssoConfig]); + }, [saml, ssoConfig, startBrokeredSso]); const ssoEnabled = ssoConfig?.ssoEnabled ?? false; @@ -158,7 +176,7 @@ export default function SsoLogin() { onOidcPress={handleOidcPress} onSamlPress={handleSamlPress} onChangeDepartment={handleChangeDepartment} - oidcRequestReady={!!oidc.request} + oidcRequestReady={ssoConfig.brokeredSsoAvailable || !!oidc.request} isLoading={status === 'loading'} /> ) : ( @@ -173,6 +191,9 @@ export default function SsoLogin() { )} + {/* Second factor after a brokered sign-in, on the login transaction the broker's redemption started */} + router.push('/login/recovery')} /> + {/* Two-factor challenge: SSO exchange answered mfa_required / invalid_totp */} , + icon: , }, { titleKey: 'onboarding.slides.notifications.title', descriptionKey: 'onboarding.slides.notifications.description', - icon: , + icon: , }, { titleKey: 'onboarding.slides.calls.title', descriptionKey: 'onboarding.slides.calls.description', - icon: , + icon: , }, ]; -const OnboardingItem: React.FC = ({ title, description, icon }) => { - const { colorScheme } = useColorScheme(); - - // Compute dynamic colors once using useMemo - const textColors = useMemo(() => { - const isDark = colorScheme === 'dark'; - return { - title: isDark ? COLORS.text.dark.primary : COLORS.text.light.primary, - description: isDark ? COLORS.text.dark.secondary : COLORS.text.light.secondary, - }; - }, [colorScheme]); - - return ( - - {icon} - {title} - {description} - - ); -}; - -const Pagination: React.FC<{ currentIndex: number; items: OnboardingSlide[] }> = ({ currentIndex, items }) => { - return ( - - {items.map((item, index) => ( - - ))} - - ); -}; - export default function Onboarding() { const { t } = useTranslation(); - const [_, setIsFirstTime] = useIsFirstTime(); - //const { setIsOnboarding } = useAuthStore(); + const [, setIsFirstTime] = useIsFirstTime(); const { trackEvent } = useAnalytics(); const router = useRouter(); const [currentIndex, setCurrentIndex] = useState(0); - const swipeStartXRef = useRef(null); - const buttonOpacity = useSharedValue(0); - const { colorScheme } = useColorScheme(); - - // Initialize button opacity when reaching the last slide - React.useEffect(() => { - if (currentIndex === onboardingData.length - 1) { - buttonOpacity.value = withTiming(1, { duration: 500 }); - } else { - buttonOpacity.value = withTiming(0, { duration: 300 }); - } - }, [currentIndex, buttonOpacity]); - - //useEffect(() => { - // setIsOnboarding(); - //}, [setIsOnboarding]); - // Analytics: Track when the onboarding page is viewed useFocusEffect( useCallback(() => { @@ -217,45 +108,6 @@ export default function Onboarding() { } }, [currentIndex, goToSlide, trackEvent, getSlideTitle]); - const handleSlideTouchStart = useCallback((event: GestureResponderEvent) => { - swipeStartXRef.current = typeof event.nativeEvent.pageX === 'number' ? event.nativeEvent.pageX : null; - }, []); - - const handleSlideTouchEnd = useCallback( - (event: GestureResponderEvent) => { - const startX = swipeStartXRef.current; - const endX = typeof event.nativeEvent.pageX === 'number' ? event.nativeEvent.pageX : null; - - swipeStartXRef.current = null; - - if (startX === null || endX === null) { - return; - } - - const deltaX = endX - startX; - - if (deltaX <= -SWIPE_THRESHOLD) { - if (currentIndex < onboardingData.length - 1) { - goToSlide(currentIndex + 1); - } else { - handleGetStarted(); - } - } - - if (deltaX >= SWIPE_THRESHOLD && currentIndex > 0) { - goToSlide(currentIndex - 1); - } - }, - [currentIndex, goToSlide, handleGetStarted] - ); - - const buttonAnimatedStyle = useAnimatedStyle(() => { - return { - opacity: buttonOpacity.value, - transform: [{ translateY: (1 - buttonOpacity.value) * 20 }], - }; - }); - const handleSkip = useCallback(() => { // Analytics: Track skip button clicks trackEvent('onboarding_skip_clicked', { @@ -269,73 +121,21 @@ export default function Onboarding() { router.replace('/login'); }, [trackEvent, currentIndex, setIsFirstTime, router, getSlideTitle]); - const handleSkipBottom = useCallback(() => { - // Analytics: Track skip button clicks - trackEvent('onboarding_skip_clicked', { - timestamp: new Date().toISOString(), - currentSlide: currentIndex, - slideTitle: getSlideTitle(currentIndex), - }); - - setIsFirstTime(false); - router.replace('/login'); - }, [trackEvent, currentIndex, setIsFirstTime, router, getSlideTitle]); - - const actionTextColor = colorScheme === 'dark' ? '#000000' : '#FFFFFF'; const currentSlide = onboardingData[currentIndex]; - return ( - - + ); } diff --git a/src/app/sso-return.tsx b/src/app/sso-return.tsx new file mode 100644 index 00000000..31f24861 --- /dev/null +++ b/src/app/sso-return.tsx @@ -0,0 +1,32 @@ +import { useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; +import React, { useEffect } from 'react'; +import { Platform, View } from 'react-native'; + +import { Spinner } from '@/components/ui/spinner'; + +// The page the Resgrid SSO broker returns to (passkey plan section 7.7.2). The browser auth session that opened the +// provider reads the one-time code from this URL; on the web this popup hands it back and closes. Nothing is read or +// stored here, and a stray visit simply goes back. +WebBrowser.maybeCompleteAuthSession(); + +export default function SsoReturn() { + const router = useRouter(); + + useEffect(() => { + if (Platform.OS === 'web') { + return; + } + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/login'); + } + }, [router]); + + return ( + + + + ); +} diff --git a/src/components/auth/__tests__/login-mfa-sheet.test.tsx b/src/components/auth/__tests__/login-mfa-sheet.test.tsx new file mode 100644 index 00000000..fc33f7e6 --- /dev/null +++ b/src/components/auth/__tests__/login-mfa-sheet.test.tsx @@ -0,0 +1,159 @@ +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import type { MfaChallenge } from '@/lib/mfa/types'; +import useAuthStore from '@/stores/auth/store'; + +import { LoginMfaSheet } from '../login-mfa-sheet'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/components/ui/modal', () => { + const { View } = require('react-native'); + return { + Modal: ({ isOpen, children, ...props }: any) => (isOpen ? {children} : null), + ModalBackdrop: () => null, + ModalBody: ({ children }: any) => {children}, + ModalContent: ({ children }: any) => {children}, + ModalFooter: ({ children }: any) => {children}, + ModalHeader: ({ children }: any) => {children}, + }; +}); +jest.mock('@/lib/mfa/passkey', () => ({ passkeysSupported: jest.fn(() => true) })); +jest.mock('expo-linking', () => ({ openURL: jest.fn() })); + +const challenge = (overrides: Partial = {}): MfaChallenge => ({ + kind: 'verify', + methods: ['totp', 'passkey', 'passkey_approval', 'federated'], + enrolled: ['totp', 'passkey', 'passkey_approval', 'federated'], + preferred: 'totp', + expiresAt: null, + source: 'password', + ...overrides, +}); + +describe('LoginMfaSheet', () => { + const verifyLoginMfa = jest.fn(); + const requestLoginApproval = jest.fn(); + const waitForLoginApproval = jest.fn(); + const cancelLoginApproval = jest.fn(); + const cancelLoginMfa = jest.fn(); + const loginSetupOptions = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + verifyLoginMfa.mockResolvedValue({ ok: true, recovery: false }); + useAuthStore.setState({ mfaChallenge: challenge(), verifyLoginMfa, requestLoginApproval, waitForLoginApproval, cancelLoginApproval, cancelLoginMfa, loginSetupOptions }); + }); + + it('offers every method this sign-in accepts and submits a code', async () => { + const { getByTestId } = render(); + expect(getByTestId('login-mfa-passkey')).toBeTruthy(); + expect(getByTestId('login-mfa-approval')).toBeTruthy(); + expect(getByTestId('login-mfa-provider')).toBeTruthy(); + + fireEvent.changeText(getByTestId('login-mfa-code'), '123456'); + await act(async () => fireEvent.press(getByTestId('login-mfa-submit'))); + expect(verifyLoginMfa).toHaveBeenCalledWith({ method: 'totp', code: '123456' }); + }); + + it('offers only the accepted methods the member has', () => { + useAuthStore.setState({ mfaChallenge: challenge({ enrolled: ['totp', 'passkey'] }) }); + const { getByTestId, queryByTestId } = render(); + expect(getByTestId('login-mfa-code')).toBeTruthy(); + expect(getByTestId('login-mfa-passkey')).toBeTruthy(); + expect(queryByTestId('login-mfa-approval')).toBeNull(); + expect(queryByTestId('login-mfa-provider')).toBeNull(); + }); + + it('shows a member who signs in only through the provider no code box', () => { + useAuthStore.setState({ mfaChallenge: challenge({ methods: ['totp', 'federated'], enrolled: ['federated'], preferred: 'federated' }) }); + const { getByTestId, queryByTestId } = render(); + expect(getByTestId('login-mfa-provider')).toBeTruthy(); + expect(queryByTestId('login-mfa-code')).toBeNull(); + expect(queryByTestId('login-mfa-recovery-toggle')).toBeNull(); + }); + + it('keeps every accepted method from a server that sends no member list', () => { + useAuthStore.setState({ mfaChallenge: challenge({ enrolled: [] }) }); + const { getByTestId } = render(); + expect(getByTestId('login-mfa-approval')).toBeTruthy(); + expect(getByTestId('login-mfa-provider')).toBeTruthy(); + }); + + it('offers only what the server lists, and uses a recovery code on request', async () => { + useAuthStore.setState({ mfaChallenge: challenge({ methods: ['totp'] }) }); + const { queryByTestId, getByTestId } = render(); + expect(queryByTestId('login-mfa-passkey')).toBeNull(); + expect(queryByTestId('login-mfa-approval')).toBeNull(); + + fireEvent.press(getByTestId('login-mfa-recovery-toggle')); + fireEvent.changeText(getByTestId('login-mfa-code'), 'ABCD-EFGH'); + await act(async () => fireEvent.press(getByTestId('login-mfa-submit'))); + expect(verifyLoginMfa).toHaveBeenCalledWith({ method: 'recovery_code', code: 'ABCD-EFGH' }); + }); + + it('shows why a code failed, in the member\'s language', async () => { + verifyLoginMfa.mockResolvedValue({ ok: false, code: 'invalid_totp', restart: false }); + const { getByTestId } = render(); + fireEvent.changeText(getByTestId('login-mfa-code'), '000000'); + await act(async () => fireEvent.press(getByTestId('login-mfa-submit'))); + expect(getByTestId('login-mfa-error').props.children).toBe('mfa.errors.invalid_totp'); + }); + + it('lets the member try again when a step fails unexpectedly', async () => { + verifyLoginMfa.mockRejectedValueOnce(new Error('boom')); + const { getByTestId } = render(); + fireEvent.changeText(getByTestId('login-mfa-code'), '000000'); + await act(async () => fireEvent.press(getByTestId('login-mfa-submit'))); + expect(getByTestId('login-mfa-error').props.children).toBe('mfa.errors.unknown_error'); + + fireEvent.changeText(getByTestId('login-mfa-code'), '123456'); + await act(async () => fireEvent.press(getByTestId('login-mfa-submit'))); + expect(verifyLoginMfa).toHaveBeenLastCalledWith({ method: 'totp', code: '123456' }); + }); + + it('shows the approval number on this screen and finishes once Responder approves', async () => { + requestLoginApproval.mockResolvedValue({ ApprovalRequestId: 'ap-1', MatchNumber: '42', ExpiresIn: 120 }); + let decide: (value: string) => void = () => undefined; + waitForLoginApproval.mockReturnValue(new Promise((resolve) => (decide = resolve))); + + const { getByTestId, queryByTestId } = render(); + await act(async () => fireEvent.press(getByTestId('login-mfa-approval'))); + expect(getByTestId('mfa-approval-number').props.children).toBe('42'); + expect(queryByTestId('login-mfa-code')).toBeNull(); + + await act(async () => decide('approved')); + await waitFor(() => expect(verifyLoginMfa).toHaveBeenCalledWith({ method: 'passkey_approval', approvalRequestId: 'ap-1' })); + }); + + it('stops waiting and cancels the request when the member stops', async () => { + requestLoginApproval.mockResolvedValue({ ApprovalRequestId: 'ap-1', MatchNumber: '42', ExpiresIn: 120 }); + waitForLoginApproval.mockReturnValue(new Promise(() => undefined)); + const { getByTestId, queryByTestId } = render(); + await act(async () => fireEvent.press(getByTestId('login-mfa-approval'))); + await act(async () => fireEvent.press(getByTestId('mfa-approval-cancel'))); + expect(cancelLoginApproval).toHaveBeenCalledWith('ap-1'); + expect(queryByTestId('mfa-approval-panel')).toBeNull(); + }); + + it('sets up the required authenticator with a new key before signing in', async () => { + useAuthStore.setState({ mfaChallenge: challenge({ kind: 'setup', methods: ['totp'], enrolled: [] }) }); + loginSetupOptions.mockResolvedValue({ SharedKey: 'ABCD EFGH', AuthenticatorUri: 'otpauth://totp/x', ExpiresIn: 600 }); + const { getByTestId, queryByTestId } = render(); + await waitFor(() => expect(getByTestId('mfa-setup-shared-key').props.children).toBe('ABCD EFGH')); + expect(queryByTestId('login-mfa-lost')).toBeNull(); + + fireEvent.changeText(getByTestId('login-mfa-code'), '123456'); + await act(async () => fireEvent.press(getByTestId('login-mfa-submit'))); + expect(verifyLoginMfa).toHaveBeenCalledWith({ method: 'setup', code: '123456' }); + }); + + it('cancels the sign-in and offers the lost-authenticator path', async () => { + const onLostFactor = jest.fn(); + const { getByTestId } = render(); + fireEvent.press(getByTestId('login-mfa-lost')); + expect(onLostFactor).toHaveBeenCalled(); + await act(async () => fireEvent.press(getByTestId('login-mfa-cancel'))); + expect(cancelLoginMfa).toHaveBeenCalled(); + }); +}); diff --git a/src/components/auth/login-mfa-sheet.tsx b/src/components/auth/login-mfa-sheet.tsx new file mode 100644 index 00000000..aa159fca --- /dev/null +++ b/src/components/auth/login-mfa-sheet.tsx @@ -0,0 +1,287 @@ +import * as Linking from 'expo-linking'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ApprovalPanel } from '@/components/mfa/approval-panel'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { Input, InputField } from '@/components/ui/input'; +import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; +import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { mfaErrorKey } from '@/lib/mfa/messages'; +import { passkeysSupported } from '@/lib/mfa/passkey'; +import type { MfaMethod, TotpSetupData } from '@/lib/mfa/types'; +import useAuthStore from '@/stores/auth/store'; + +interface LoginMfaSheetProps { + isOpen: boolean; + /** "I lost my authenticator": start the restricted recovery with a recovery code. */ + onLostFactor: () => void; +} + +type Mode = 'choose' | 'recovery'; + +/** + * The second step of signing in on a login transaction (passkey plan section 7.5): the methods this sign-in accepts, as + * equal choices with the member's last one first. It also carries setting up the authenticator a department requires + * (section 6.2). Codes live only in local state and are cleared after every attempt; nothing here is logged or stored. + */ +export const LoginMfaSheet: React.FC = ({ isOpen, onLostFactor }) => { + const { t } = useTranslation(); + const challenge = useAuthStore((s) => s.mfaChallenge); + const [mode, setMode] = useState('choose'); + const [code, setCode] = useState(''); + const [busy, setBusy] = useState(false); + const [errorCode, setErrorCode] = useState(null); + const [approval, setApproval] = useState<{ id: string; number: string } | null>(null); + const [setup, setSetup] = useState(null); + const approvalAbort = useRef(null); + + const isSetup = challenge?.kind === 'setup'; + const methods = useMemo(() => { + // The accepted methods the member has (plan section 7.5, as Core Web shows them): the server lists the two apart, and a + // method the member lacks could only fail. A server that sends no member list keeps the full one. + const enrolled = challenge?.enrolled ?? []; + const usable = (challenge?.methods ?? []).filter((m) => enrolled.length === 0 || enrolled.includes(m)); + const available = usable.filter((m) => m !== 'passkey' || passkeysSupported()); + const preferred = challenge?.preferred; + return preferred && available.includes(preferred) ? [preferred, ...available.filter((m) => m !== preferred)] : available; + }, [challenge]); + const recoveryAvailable = !isSetup && (challenge?.enrolled ?? []).includes('totp'); + + const stopApproval = useCallback(() => { + approvalAbort.current?.abort(); + approvalAbort.current = null; + }, []); + + useEffect(() => { + if (!isOpen) { + stopApproval(); + setMode('choose'); + setCode(''); + setErrorCode(null); + setApproval(null); + setSetup(null); + setBusy(false); + } + }, [isOpen, stopApproval]); + + useEffect(() => stopApproval, [stopApproval]); + + // Setting up the required authenticator: a new key, activated only when a code from it verifies. + useEffect(() => { + if (!isOpen || !isSetup || setup) { + return; + } + let cancelled = false; + void useAuthStore + .getState() + .loginSetupOptions() + .then((result) => { + if (cancelled) { + return; + } + if ('SharedKey' in result) { + setSetup(result); + } else { + setErrorCode(result.code); + } + }); + return () => { + cancelled = true; + }; + }, [isOpen, isSetup, setup]); + + const run = useCallback(async (action: () => Promise<{ ok: boolean; code?: string }>) => { + setBusy(true); + setErrorCode(null); + try { + const result = await action(); + if (!result.ok) { + setErrorCode(result.code ?? 'unknown_error'); + } + } catch { + setErrorCode('unknown_error'); + } finally { + setBusy(false); + } + }, []); + + const submitCode = useCallback(() => { + const submitted = code.trim(); + if (submitted.length === 0) { + return; + } + setCode(''); + const method = isSetup ? 'setup' : mode === 'recovery' ? 'recovery_code' : 'totp'; + void run(async () => { + const result = await useAuthStore.getState().verifyLoginMfa({ method, code: submitted }); + return result.ok ? { ok: true } : { ok: false, code: result.code }; + }); + }, [code, isSetup, mode, run]); + + const verifyWithPasskey = useCallback(() => { + void run(async () => { + const result = await useAuthStore.getState().verifyLoginMfa({ method: 'passkey' }); + return result.ok ? { ok: true } : { ok: false, code: result.code }; + }); + }, [run]); + + const verifyWithProvider = useCallback(() => { + void run(async () => { + const result = await useAuthStore.getState().verifyLoginMfa({ method: 'federated' }); + return result.ok ? { ok: true } : { ok: false, code: result.code }; + }); + }, [run]); + + const verifyWithApproval = useCallback(async () => { + setBusy(true); + setErrorCode(null); + const store = useAuthStore.getState(); + const started = await store.requestLoginApproval(); + if (!('ApprovalRequestId' in started)) { + setBusy(false); + setErrorCode(started.code); + return; + } + + setApproval({ id: started.ApprovalRequestId, number: started.MatchNumber }); + const controller = new AbortController(); + approvalAbort.current = controller; + const decided = await store.waitForLoginApproval(started.ApprovalRequestId, controller.signal); + if (decided === 'aborted') { + return; + } + setApproval(null); + if (decided !== 'approved') { + setBusy(false); + setErrorCode(decided === 'denied' ? 'approval_denied' : decided === 'unavailable' ? 'service_unavailable' : 'approval_expired'); + return; + } + const result = await store.verifyLoginMfa({ method: 'passkey_approval', approvalRequestId: started.ApprovalRequestId }); + setBusy(false); + if (!result.ok) { + setErrorCode(result.code); + } + }, []); + + const cancelApproval = useCallback(() => { + const current = approval; + stopApproval(); + setApproval(null); + setBusy(false); + if (current) { + void useAuthStore.getState().cancelLoginApproval(current.id); + } + }, [approval, stopApproval]); + + const cancelSignIn = useCallback(() => { + cancelApproval(); + useAuthStore.getState().cancelLoginMfa(); + }, [cancelApproval]); + + const showsCodeInput = isSetup ? !!setup : mode === 'recovery' || methods.includes('totp'); + + return ( + + + + + {isSetup ? t('mfa.setup.title') : t('mfa.login.title')} + + + + {isSetup ? t('mfa.setup.body') : mode === 'recovery' ? t('mfa.login.recovery_body') : t('mfa.login.body')} + + {isSetup && setup ? ( + + + {t('mfa.setup.key_label')} + + + {setup.SharedKey} + + + + ) : null} + + {approval ? : null} + + {!approval && showsCodeInput ? ( + + + + ) : null} + + {!approval && showsCodeInput ? ( + + ) : null} + + {!approval && !isSetup && mode === 'choose' && methods.includes('passkey') ? ( + + ) : null} + {!approval && !isSetup && mode === 'choose' && methods.includes('passkey_approval') ? ( + + ) : null} + {!approval && !isSetup && mode === 'choose' && methods.includes('federated') ? ( + + ) : null} + + {errorCode ? ( + + {t(mfaErrorKey(errorCode))} + + ) : null} + + {!approval && recoveryAvailable ? ( + setMode(mode === 'recovery' ? 'choose' : 'recovery')} testID="login-mfa-recovery-toggle" accessibilityRole="button"> + + {mode === 'recovery' ? t('mfa.login.use_other_method') : t('mfa.login.use_recovery_code')} + + + ) : null} + {!approval && !isSetup ? ( + + + {t('mfa.login.lost_factor')} + + + ) : null} + + + + + + + + ); +}; diff --git a/src/components/checklists/__tests__/checklist-run-sheet.test.tsx b/src/components/checklists/__tests__/checklist-run-sheet.test.tsx index 6d68a78d..5c7e957a 100644 --- a/src/components/checklists/__tests__/checklist-run-sheet.test.tsx +++ b/src/components/checklists/__tests__/checklist-run-sheet.test.tsx @@ -7,11 +7,22 @@ jest.mock('expo-location', () => ({ requestForegroundPermissionsAsync: jest.fn() jest.mock('expo-file-system/legacy', () => ({ cacheDirectory: 'file:///cache/', deleteAsync: jest.fn() })); jest.mock('@/api/checklists/checklists', () => ({ attestChecklistRun: jest.fn() })); jest.mock('@/components/checklists/signature-pad', () => ({ ChecklistSignaturePad: () => null })); -jest.mock('@/components/ui/button', () => { const { Pressable, Text } = require('react-native'); return { Button: ({ isDisabled, ...props }: { isDisabled?: boolean }) => , ButtonText: Text }; }); +jest.mock('@/components/ui/button', () => { + const { Pressable, Text } = require('react-native'); + return { Button: ({ isDisabled, ...props }: { isDisabled?: boolean }) => , ButtonText: Text }; +}); jest.mock('@/components/ui/text', () => ({ Text: require('react-native').Text })); jest.mock('react-native-safe-area-context', () => ({ SafeAreaView: require('react-native').View })); -jest.mock('@/stores/auth/store', () => { const { create } = jest.requireActual('zustand'); return { __esModule: true, default: create(() => ({ userId: 'author' })) }; }); -jest.mock('@/stores/checklists/store', () => { const { create } = jest.requireActual('zustand'); return { useChecklistsStore: create(() => ({ active: null, update: jest.fn().mockResolvedValue(undefined), queue: jest.fn().mockResolvedValue(undefined), close: jest.fn(), addImage: jest.fn().mockResolvedValue(undefined) })) }; }); +jest.mock('@/stores/auth/store', () => { + const { create } = jest.requireActual('zustand'); + return { __esModule: true, default: create(() => ({ userId: 'author' })) }; +}); +jest.mock('@/stores/checklists/store', () => { + const { create } = jest.requireActual('zustand'); + return { + useChecklistsStore: create(() => ({ active: null, update: jest.fn().mockResolvedValue(undefined), queue: jest.fn().mockResolvedValue(undefined), close: jest.fn(), addImage: jest.fn().mockResolvedValue(undefined) })), + }; +}); import React from 'react'; import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; import * as ImagePicker from 'expo-image-picker'; @@ -21,32 +32,73 @@ import { ChecklistRunSheet } from '@/components/checklists/checklist-run-sheet'; import { draft } from '@/lib/checklists/__tests__/fixtures'; import { useChecklistsStore } from '@/stores/checklists/store'; -beforeEach(() => { jest.clearAllMocks(); const local = draft(); local.queued = false; useChecklistsStore.setState({ active: local }); }); -it.each([0,1,2,3,4,5,6,7,8,9])('renders question type %i with localized controls', type => { - const local = draft(); local.queued = false; local.run.Form.Sections[0].Items[0].Type = type; local.run.Form.Sections[0].Items[0].Options = ['Synthetic option']; - useChecklistsStore.setState({ active: local }); const screen = render(); +beforeEach(() => { + jest.clearAllMocks(); + const local = draft(); + local.queued = false; + useChecklistsStore.setState({ active: local }); +}); +it.each([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])('renders question type %i with localized controls', (type) => { + const local = draft(); + local.queued = false; + local.run.Form.Sections[0].Items[0].Type = type; + local.run.Form.Sections[0].Items[0].Options = ['Synthetic option']; + useChecklistsStore.setState({ active: local }); + const screen = render(); expect(screen.getByText('Ready (checklists.labels.Required)')).toBeTruthy(); - if ([3,4,5,7].includes(type)) { fireEvent.changeText(screen.getByLabelText('checklists.labels.Answer'), '12'); expect(useChecklistsStore.getState().update).toHaveBeenCalledWith(expect.objectContaining({ Answers: [expect.objectContaining({ Value: '12' })] })); } - if ([0,1,2,6].includes(type)) { fireEvent.press(screen.getByText(type===0 ? 'checklists.labels.Pass' : type===1 ? 'checklists.labels.Yes' : type===2 ? 'checklists.labels.Checked' : 'Synthetic option')); expect(useChecklistsStore.getState().update).toHaveBeenCalled(); } - if (type===8) expect(screen.getByText('checklists.labels.Photo')).toBeTruthy(); - if (type===9) expect(screen.getByText('checklists.labels.Signature')).toBeTruthy(); + if ([3, 4, 5].includes(type)) { + fireEvent.changeText(screen.getByLabelText('checklists.labels.Answer'), '12'); + expect(useChecklistsStore.getState().update).toHaveBeenCalledWith(expect.objectContaining({ Answers: [expect.objectContaining({ Value: '12' })] })); + } + if ([0, 1, 2, 6].includes(type)) { + fireEvent.press(screen.getByText(type === 0 ? 'checklists.labels.Pass' : type === 1 ? 'checklists.labels.Yes' : type === 2 ? 'checklists.labels.Checked' : 'Synthetic option')); + expect(useChecklistsStore.getState().update).toHaveBeenCalled(); + } + if (type === 8) expect(screen.getByText('checklists.labels.Photo')).toBeTruthy(); + if (type === 9) expect(screen.getByText('checklists.labels.Signature')).toBeTruthy(); screen.unmount(); }); it('queues progress and submission through the durable store', async () => { - const screen=render(); fireEvent.press(screen.getByText('checklists.labels.SaveProgress')); - await waitFor(()=>expect(useChecklistsStore.getState().queue).toHaveBeenCalledWith(false)); - fireEvent.press(screen.getByText('checklists.labels.SubmitRun')); await waitFor(()=>expect(useChecklistsStore.getState().queue).toHaveBeenCalledWith(true)); screen.unmount(); + const screen = render(); + fireEvent.press(screen.getByText('checklists.labels.SaveProgress')); + await waitFor(() => expect(useChecklistsStore.getState().queue).toHaveBeenCalledWith(false)); + fireEvent.press(screen.getByText('checklists.labels.SubmitRun')); + await waitFor(() => expect(useChecklistsStore.getState().queue).toHaveBeenCalledWith(true)); + screen.unmount(); }); it('normalizes photo bytes and deletes only app cache copies after encrypted persistence', async () => { - const local=draft(); local.queued=false; local.run.Form.Sections[0].Items[0].Type=8; useChecklistsStore.setState({active:local}); - jest.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue({granted:true} as never); - jest.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({canceled:false,assets:[{uri:'file:///gallery/original.heic',width:4000,height:3000}]} as never); - jest.mocked(manipulateAsync).mockResolvedValue({uri:'file:///cache/converted.jpg',width:2048,height:1536,base64:'synthetic-image'}); - const screen=render(); fireEvent.press(screen.getByText('checklists.labels.Photo')); - await waitFor(()=>expect(useChecklistsStore.getState().addImage).toHaveBeenCalledWith(expect.objectContaining({base64:'synthetic-image',contentType:'image/jpeg'}))); - expect(FileSystem.deleteAsync).toHaveBeenCalledWith('file:///cache/converted.jpg',{idempotent:true}); expect(FileSystem.deleteAsync).not.toHaveBeenCalledWith('file:///gallery/original.heic',expect.anything()); screen.unmount(); + const local = draft(); + local.queued = false; + local.run.Form.Sections[0].Items[0].Type = 8; + useChecklistsStore.setState({ active: local }); + jest.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue({ granted: true } as never); + jest.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ canceled: false, assets: [{ uri: 'file:///gallery/original.heic', width: 4000, height: 3000 }] } as never); + jest.mocked(manipulateAsync).mockResolvedValue({ uri: 'file:///cache/converted.jpg', width: 2048, height: 1536, base64: 'synthetic-image' }); + const screen = render(); + fireEvent.press(screen.getByText('checklists.labels.Photo')); + await waitFor(() => expect(useChecklistsStore.getState().addImage).toHaveBeenCalledWith(expect.objectContaining({ base64: 'synthetic-image', contentType: 'image/jpeg' }))); + expect(FileSystem.deleteAsync).toHaveBeenCalledWith('file:///cache/converted.jpg', { idempotent: true }); + expect(FileSystem.deleteAsync).not.toHaveBeenCalledWith('file:///gallery/original.heic', expect.anything()); + screen.unmount(); }); it('removes entered values from the screen as soon as the store conceals the draft', () => { - const screen=render(); act(() => useChecklistsStore.setState({active:null})); screen.rerender(); - expect(screen.queryByText('Synthetic readiness check')).toBeNull(); screen.unmount(); + const screen = render(); + act(() => useChecklistsStore.setState({ active: null })); + screen.rerender(); + expect(screen.queryByText('Synthetic readiness check')).toBeNull(); + screen.unmount(); +}); + +it('saves a date answer selected from the calendar', () => { + const local = draft(); + local.queued = false; + local.run.Form.Sections[0].Items[0].Type = 7; + local.input.Answers = [{ ItemId: local.run.Form.Sections[0].Items[0].Id, Status: 1, Value: '2024-02-01' }]; + useChecklistsStore.setState({ active: local }); + const screen = render(); + fireEvent.press(screen.getByTestId('checklist-answer-date')); + fireEvent.press(screen.getByTestId('checklist-answer-date-day-29')); + fireEvent.press(screen.getByTestId('checklist-answer-date-done')); + expect(useChecklistsStore.getState().update).toHaveBeenCalledWith(expect.objectContaining({ Answers: [expect.objectContaining({ Value: '2024-02-29', Status: 1 })] })); + screen.unmount(); }); diff --git a/src/components/checklists/checklist-run-sheet.tsx b/src/components/checklists/checklist-run-sheet.tsx index d2785334..15cae066 100644 --- a/src/components/checklists/checklist-run-sheet.tsx +++ b/src/components/checklists/checklist-run-sheet.tsx @@ -10,6 +10,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { attestChecklistRun, getChecklistImage } from '@/api/checklists/checklists'; import { ChecklistSignaturePad } from '@/components/checklists/signature-pad'; +import { DateTimeField } from '@/components/common/date-time-field'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { checklistFailed, isChecklistRequired, visibleChecklistItems } from '@/lib/checklists/form'; @@ -132,7 +133,10 @@ export const ChecklistRunSheet: React.FC = () => { {item.Type === 6 ? label : t(`checklists.labels.${label}`)} ))} - {[3, 4, 5, 7].includes(item.Type) ? ( + {item.Type === 7 ? ( + change({ Status: Value ? 1 : 0, Value })} label={item.Name} disabled={!editable || busy} testID="checklist-answer-date" /> + ) : null} + {[3, 4, 5].includes(item.Type) ? ( { multiline={item.Type === 5} keyboardType={[3, 4].includes(item.Type) ? 'decimal-pad' : 'default'} value={answer?.Value ?? ''} - placeholder={item.Type === 7 ? 'YYYY-MM-DD' : (item.Units ?? '')} + placeholder={item.Units ?? ''} onChangeText={(Value) => change({ Status: Value ? 1 : 0, Value })} /> ) : null} diff --git a/src/components/common/__tests__/date-time-field.test.tsx b/src/components/common/__tests__/date-time-field.test.tsx new file mode 100644 index 00000000..4cec15c3 --- /dev/null +++ b/src/components/common/__tests__/date-time-field.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { TextInput } from 'react-native'; + +import { DateTimeField } from '@/components/common/date-time-field'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }) })); +jest.mock('react-native-safe-area-context', () => ({ SafeAreaView: require('react-native').View })); + +it('selects a leap day using the calendar and commits only on Done', () => { + const onChange = jest.fn(); + const screen = render(); + expect(screen.UNSAFE_queryAllByType(TextInput)).toHaveLength(0); + fireEvent.press(screen.getByTestId('date')); + expect(screen.queryByTestId('date-day-30')).toBeNull(); + fireEvent.press(screen.getByTestId('date-day-29')); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.press(screen.getByTestId('date-done')); + expect(onChange).toHaveBeenCalledWith('2024-02-29'); + screen.unmount(); +}); + +it('changes month and year without rolling an end-of-month value into another month', () => { + const onChange = jest.fn(); + const screen = render(); + fireEvent.press(screen.getByTestId('date')); + fireEvent.press(screen.getByTestId('date-year')); + fireEvent.press(screen.getByTestId('date-year-2025')); + fireEvent.press(screen.getByTestId('date-month')); + fireEvent.press(screen.getByTestId('date-month-1')); + expect(screen.queryByTestId('date-day-29')).toBeNull(); + fireEvent.press(screen.getByTestId('date-day-28')); + fireEvent.press(screen.getByTestId('date-done')); + expect(onChange).toHaveBeenCalledWith('2025-02-28'); + screen.unmount(); +}); + +it('cancels without changing the value, supports clearing, and prevents disabled edits', () => { + const onChange = jest.fn(); + const screen = render(); + fireEvent.press(screen.getByTestId('date')); + fireEvent.press(screen.getByTestId('date-day-15')); + fireEvent.press(screen.getByTestId('date-cancel')); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.press(screen.getByTestId('date')); + fireEvent.press(screen.getByTestId('date-clear')); + expect(onChange).toHaveBeenCalledWith(''); + screen.rerender(); + fireEvent.press(screen.getByTestId('date')); + expect(screen.queryByTestId('date-done')).toBeNull(); + screen.unmount(); +}); + +it('picks clock hours and minutes without a keyboard or clearing a required time', () => { + const onChange = jest.fn(); + const screen = render(); + fireEvent.press(screen.getByTestId('time')); + expect(screen.UNSAFE_queryAllByType(TextInput)).toHaveLength(0); + expect(screen.queryByTestId('time-clear')).toBeNull(); + fireEvent.press(screen.getByTestId('time-hour-23')); + fireEvent.press(screen.getByTestId('time-minute-59')); + fireEvent.press(screen.getByTestId('time-done')); + expect(onChange).toHaveBeenCalledWith('23:59'); + screen.unmount(); +}); + +it('preserves a record timestamp when confirming its date and time without changes', () => { + const onChange = jest.fn(); + const screen = render(); + fireEvent.press(screen.getByTestId('date')); + fireEvent.press(screen.getByTestId('date-done')); + expect(onChange).toHaveBeenCalledWith('2026-09-26T21:15:00.000Z'); + screen.unmount(); +}); diff --git a/src/components/common/date-time-field.tsx b/src/components/common/date-time-field.tsx new file mode 100644 index 00000000..d6aec6c7 --- /dev/null +++ b/src/components/common/date-time-field.tsx @@ -0,0 +1,204 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Pressable, ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { Text } from '@/components/ui/text'; +import { parsePickerValue, pickerDate, type PickerMode, pickerTime, serializePickerValue } from '@/lib/date-time-picker'; + +interface DateTimeFieldProps { + value: string; + onChange: (value: string) => void; + label: string; + mode?: PickerMode; + disabled?: boolean; + clearable?: boolean; + testID?: string; +} + +// A page of 24 years, kept within years 1-9999. +const yearPageStart = (start: number) => Math.max(1, Math.min(9976, start)); + +// One picker for native and web. No keyboard entry; cancel never commits the draft. +// Date-only and clock-only values keep their calendar day/clock across time zones. +export const DateTimeField = ({ value, onChange, label, mode = 'date', disabled = false, clearable = true, testID = 'date-time-field' }: DateTimeFieldProps) => { + const { t, i18n } = useTranslation(); + const locale = i18n?.language ?? 'en'; + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(() => parsePickerValue(value, mode)); + const [month, setMonth] = useState(draft.getMonth()); + const [year, setYear] = useState(draft.getFullYear()); + const [view, setView] = useState<'days' | 'months' | 'years'>('days'); + const [yearPage, setYearPage] = useState(() => yearPageStart(year - 11)); + const selected = parsePickerValue(value, mode); + const display = !value ? label : mode === 'time' ? pickerTime(selected) : mode === 'date' ? selected.toLocaleDateString(locale) : selected.toLocaleString(locale); + const show = () => { + if (disabled) return; + const next = parsePickerValue(value, mode); + setDraft(next); + setMonth(next.getMonth()); + setYear(next.getFullYear()); + setYearPage(yearPageStart(next.getFullYear() - 11)); + setView('days'); + setOpen(true); + }; + const commit = (next: string) => { + if (!disabled) onChange(next); + setOpen(false); + }; + const calendarDate = (day: number) => { + const date = new Date(draft); + date.setDate(1); + date.setFullYear(year, month, day); + return date; + }; + const monthEnd = calendarDate(1); + monthEnd.setMonth(month + 1, 0); + const move = (direction: number) => { + if (view === 'years') { + setYearPage((page) => yearPageStart(page + direction * 24)); + return; + } + const next = calendarDate(1); + next.setMonth(month + direction); + if (next.getFullYear() < 1 || next.getFullYear() > 9999) return; + setMonth(next.getMonth()); + setYear(next.getFullYear()); + }; + const choice = (text: string, id: string, onPress: () => void, chosen = false, accessibilityLabel = text) => ( + + {text} + + ); + const clockColumn = (part: 'hour' | 'minute', count: number, current: number) => ( + + {t(`dateTimePicker.${part}`)} + + {Array.from({ length: count }, (_, number) => + choice( + String(number).padStart(2, '0'), + `${part}-${number}`, + () => { + const next = new Date(draft); + if (part === 'hour') next.setHours(number, next.getMinutes(), 0, 0); + else next.setMinutes(number, 0, 0); + setDraft(next); + }, + current === number + ) + )} + + + ); + return ( + + + {value ? {label} : null} + {display} + + {open && !disabled ? ( + setOpen(false)}> + + + + {label} + + + {mode !== 'time' ? ( + + + {choice('‹', 'previous', () => move(-1), false, t('common.previous'))} + {choice(calendarDate(1).toLocaleDateString(locale, { month: 'long' }), 'month', () => setView(view === 'months' ? 'days' : 'months'))} + {choice(String(year), 'year', () => setView(view === 'years' ? 'days' : 'years'))} + {choice('›', 'next', () => move(1), false, t('common.next'))} + + {view === 'years' ? ( + + {Array.from({ length: 24 }, (_, index) => yearPage + index).map((number) => ( + + {choice( + String(number), + `year-${number}`, + () => { + setYear(number); + setView('days'); + }, + year === number + )} + + ))} + + ) : view === 'months' ? ( + + {Array.from({ length: 12 }, (_, number) => ( + + {choice( + new Date(2024, number, 1).toLocaleDateString(locale, { month: 'short' }), + `month-${number}`, + () => { + setMonth(number); + setView('days'); + }, + month === number + )} + + ))} + + ) : ( + + {Array.from({ length: 7 }, (_, day) => ( + + {new Date(2024, 0, 7 + day).toLocaleDateString(locale, { weekday: 'short' })} + + ))} + {Array.from({ length: calendarDate(1).getDay() }, (_, index) => ( + + ))} + {Array.from({ length: monthEnd.getDate() }, (_, index) => index + 1).map((day) => { + const date = calendarDate(day); + return ( + + {choice(String(day), `day-${day}`, () => setDraft(date), pickerDate(date) === pickerDate(draft), date.toLocaleDateString(locale, { dateStyle: 'full' }))} + + ); + })} + + )} + + ) : null} + {mode !== 'date' ? ( + + {clockColumn('hour', 24, draft.getHours())} + {clockColumn('minute', 60, draft.getMinutes())} + + ) : null} + + + {clearable ? choice(t('dateTimePicker.clear'), 'clear', () => commit('')) : } + {choice(t('common.cancel'), 'cancel', () => setOpen(false))} + {choice(t('common.done'), 'done', () => commit(serializePickerValue(draft, mode)), true)} + + + + + ) : null} + + ); +}; diff --git a/src/components/data-protection/__tests__/step-up-modal.test.tsx b/src/components/data-protection/__tests__/step-up-modal.test.tsx new file mode 100644 index 00000000..1ab01d2f --- /dev/null +++ b/src/components/data-protection/__tests__/step-up-modal.test.tsx @@ -0,0 +1,129 @@ +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { dataProtectionStore } from '@/stores/data-protection/store'; + +import { StepUpModal } from '../step-up-modal'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/components/ui/modal', () => { + const { View } = require('react-native'); + return { + Modal: ({ isOpen, children, ...props }: any) => (isOpen ? {children} : null), + ModalBackdrop: () => null, + ModalBody: ({ children }: any) => {children}, + ModalContent: ({ children }: any) => {children}, + ModalFooter: ({ children }: any) => {children}, + ModalHeader: ({ children }: any) => {children}, + }; +}); + +describe('StepUpModal with every method', () => { + const store = { + loadStepUpMethods: jest.fn(), + verifyOtp: jest.fn(), + verifyPasskey: jest.fn(), + verifyFederated: jest.fn(), + requestApproval: jest.fn(), + waitForApproval: jest.fn(), + completeApproval: jest.fn(), + cancelApproval: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + dataProtectionStore.setState({ ...store, stepUpMethods: ['totp', 'passkey', 'passkey_approval', 'federated'], lastError: null, isVerifying: false }); + }); + + it('loads the methods on open and offers each', () => { + const { getByTestId } = render(); + expect(store.loadStepUpMethods).toHaveBeenCalled(); + expect(getByTestId('step-up-code-input')).toBeTruthy(); + expect(getByTestId('step-up-passkey')).toBeTruthy(); + expect(getByTestId('step-up-approval')).toBeTruthy(); + expect(getByTestId('step-up-provider')).toBeTruthy(); + }); + + it('closes after a passkey or the provider verifies', async () => { + store.verifyPasskey.mockResolvedValue(true); + const onVerified = jest.fn(); + const onClose = jest.fn(); + const { getByTestId } = render(); + await act(async () => fireEvent.press(getByTestId('step-up-passkey'))); + expect(onVerified).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + + store.verifyFederated.mockResolvedValue(false); + onClose.mockClear(); + await act(async () => fireEvent.press(getByTestId('step-up-provider'))); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('shows the approval number and finishes when Responder approves', async () => { + store.requestApproval.mockResolvedValue({ id: 'ap-1', number: '42' }); + store.waitForApproval.mockResolvedValue('approved'); + store.completeApproval.mockResolvedValue(true); + const onClose = jest.fn(); + const { getByTestId } = render(); + await act(async () => fireEvent.press(getByTestId('step-up-approval'))); + await waitFor(() => expect(store.completeApproval).toHaveBeenCalledWith('ap-1')); + expect(onClose).toHaveBeenCalled(); + }); + + it('starts one approval request however often it is pressed', async () => { + let start: (value: { id: string; number: string }) => void = () => undefined; + store.requestApproval.mockReturnValue(new Promise((resolve) => (start = resolve))); + store.waitForApproval.mockReturnValue(new Promise(() => undefined)); + const { getByTestId } = render(); + + fireEvent.press(getByTestId('step-up-approval')); + fireEvent.press(getByTestId('step-up-approval')); + expect(store.requestApproval).toHaveBeenCalledTimes(1); + + await act(async () => start({ id: 'ap-1', number: '42' })); + expect(store.waitForApproval).toHaveBeenCalledTimes(1); + }); + + it('withdraws a request that arrives after the prompt closed, without waiting on it', async () => { + let start: (value: { id: string; number: string }) => void = () => undefined; + store.requestApproval.mockReturnValue(new Promise((resolve) => (start = resolve))); + const onClose = jest.fn(); + const { getByTestId, rerender } = render(); + + fireEvent.press(getByTestId('step-up-approval')); + rerender(); + await act(async () => start({ id: 'ap-1', number: '42' })); + + expect(store.cancelApproval).toHaveBeenCalledWith('ap-1'); + expect(store.waitForApproval).not.toHaveBeenCalled(); + expect(store.completeApproval).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('withdraws the waiting request on the server when the prompt closes', async () => { + store.requestApproval.mockResolvedValue({ id: 'ap-1', number: '42' }); + store.waitForApproval.mockImplementation((_id: string, signal: AbortSignal) => new Promise((resolve) => signal.addEventListener('abort', () => resolve('aborted')))); + const onClose = jest.fn(); + const { getByTestId, rerender } = render(); + await act(async () => fireEvent.press(getByTestId('step-up-approval'))); + expect(getByTestId('mfa-approval-number').props.children).toBe('42'); + + await act(async () => rerender()); + expect(store.cancelApproval).toHaveBeenCalledWith('ap-1'); + expect(store.completeApproval).not.toHaveBeenCalled(); + }); + + it('shows a method\'s refusal in the member\'s language', () => { + dataProtectionStore.setState({ lastError: 'passkey_cancelled' }); + const { getByTestId } = render(); + expect(getByTestId('step-up-error').props.children).toBe('mfa.errors.passkey_cancelled'); + }); + + it('offers only the code on an older server', () => { + dataProtectionStore.setState({ stepUpMethods: ['totp'] }); + const { queryByTestId, getByTestId } = render(); + expect(getByTestId('step-up-code-input')).toBeTruthy(); + expect(queryByTestId('step-up-passkey')).toBeNull(); + expect(queryByTestId('step-up-approval')).toBeNull(); + }); +}); diff --git a/src/components/data-protection/step-up-modal.tsx b/src/components/data-protection/step-up-modal.tsx index 5c2ace95..4a3374c4 100644 --- a/src/components/data-protection/step-up-modal.tsx +++ b/src/components/data-protection/step-up-modal.tsx @@ -1,6 +1,7 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { ApprovalPanel } from '@/components/mfa/approval-panel'; import { Button, ButtonText } from '@/components/ui/button'; import { Heading } from '@/components/ui/heading'; import { Input, InputField } from '@/components/ui/input'; @@ -8,6 +9,7 @@ import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { mfaErrorKey } from '@/lib/mfa/messages'; import { dataProtectionStore } from '@/stores/data-protection/store'; interface StepUpModalProps { @@ -28,12 +30,101 @@ export const StepUpModal: React.FC = ({ isOpen, onClose, onVer const [code, setCode] = useState(''); const isVerifying = dataProtectionStore((state) => state.isVerifying); const lastError = dataProtectionStore((state) => state.lastError); + const methods = dataProtectionStore((state) => state.stepUpMethods); + const [approval, setApproval] = useState<{ id: string; number: string } | null>(null); + const [approvalBusy, setApprovalBusy] = useState(false); + const approvalAbort = useRef(null); + // The request still waiting in the member's Responder; withdrawn on the server when this prompt closes first. + const approvalId = useRef(null); + /** Stops waiting for Responder and withdraws a request that is still waiting there. */ + const stopApproval = useCallback(() => { + approvalAbort.current?.abort(); + approvalAbort.current = null; + const waiting = approvalId.current; + approvalId.current = null; + if (waiting) { + void dataProtectionStore.getState().cancelApproval(waiting); + } + }, []); + + // The methods this member has and the department accepts for protected data (passkey plan section 8.1). useEffect(() => { - if (!isOpen) { + if (isOpen) { + void dataProtectionStore.getState().loadStepUpMethods(); + } else { setCode(''); + stopApproval(); + setApproval(null); + } + }, [isOpen, stopApproval]); + + useEffect(() => stopApproval, [stopApproval]); + + const finished = useCallback( + (ok: boolean) => { + if (ok) { + onVerified?.(); + onClose(); + } + }, + [onClose, onVerified] + ); + + const handlePasskey = useCallback(async () => finished(await dataProtectionStore.getState().verifyPasskey()), [finished]); + + const handleProvider = useCallback(async () => finished(await dataProtectionStore.getState().verifyFederated()), [finished]); + + const handleApproval = useCallback(async () => { + // One request at a time: a second press while the first is on its way would start another one on the server. + if (approvalAbort.current) { + return; } - }, [isOpen]); + const controller = new AbortController(); + approvalAbort.current = controller; + setApprovalBusy(true); + try { + const store = dataProtectionStore.getState(); + const started = await store.requestApproval(); + if (!started) { + return; + } + if (controller.signal.aborted) { + // The prompt closed while the request was on its way: nothing waits for it now, so withdraw it. + void store.cancelApproval(started.id); + return; + } + approvalId.current = started.id; + setApproval(started); + const decided = await store.waitForApproval(started.id, controller.signal); + if (decided === 'aborted' || controller.signal.aborted) { + return; + } + approvalId.current = null; + setApproval(null); + if (decided !== 'approved') { + dataProtectionStore.setState({ lastError: decided === 'denied' ? 'approval_denied' : 'approval_expired' }); + return; + } + const ok = await store.completeApproval(started.id); + // A prompt closed meanwhile reveals nothing. + if (!controller.signal.aborted) { + finished(ok); + } + } finally { + if (approvalAbort.current === controller) { + approvalAbort.current = null; + } + setApprovalBusy(false); + } + }, [finished]); + + const cancelApproval = useCallback(() => { + stopApproval(); + setApproval(null); + }, [stopApproval]); + + const offersCode = methods == null || methods.includes('totp'); const handleVerify = useCallback(async () => { const submitted = code.trim(); @@ -61,8 +152,11 @@ export const StepUpModal: React.FC = ({ isOpen, onClose, onVer return t('data_protection.step_up_unavailable', 'Protected data is not available on this server yet. Contact your administrator.'); case 'unknown': return t('data_protection.step_up_failed', 'Verification failed. Check your connection and try again.'); - default: + case null: + case undefined: return null; + default: + return t(mfaErrorKey(lastError)); } })(); @@ -76,25 +170,43 @@ export const StepUpModal: React.FC = ({ isOpen, onClose, onVer {t('data_protection.step_up_body', 'This information is protected. Enter the current code from your authenticator app to view it for a limited time.')} - - - + {approval ? : null} + {!approval && offersCode ? ( + + + + ) : null} + {!approval && methods?.includes('passkey') ? ( + + ) : null} + {!approval && methods?.includes('passkey_approval') ? ( + + ) : null} + {!approval && methods?.includes('federated') ? ( + + ) : null} {errorText ? ( // Announced on appearance: the error arrives while focus is still in the field, so // a screen reader would otherwise never reach it. @@ -108,9 +220,11 @@ export const StepUpModal: React.FC = ({ isOpen, onClose, onVer - + {offersCode && !approval ? ( + + ) : null} diff --git a/src/components/maps/__tests__/picker-department-map-center.test.tsx b/src/components/maps/__tests__/picker-department-map-center.test.tsx index c903f76a..8543821f 100644 --- a/src/components/maps/__tests__/picker-department-map-center.test.tsx +++ b/src/components/maps/__tests__/picker-department-map-center.test.tsx @@ -121,6 +121,10 @@ const CONFIGURED_CENTER: GetConfigResultData = { UnitLocationMinMeters: 0, AnalyticsApiKey: '', AnalyticsHost: '', + MapDayStyleUrl: '', + MapNightStyleUrl: '', + AppMapboxAccessToken: '', + IsDepartmentMapOverride: false, }; const lastCamera = () => mockCameraProps[mockCameraProps.length - 1]; diff --git a/src/components/maps/full-screen-location-picker.tsx b/src/components/maps/full-screen-location-picker.tsx index 00a454db..956ff980 100644 --- a/src/components/maps/full-screen-location-picker.tsx +++ b/src/components/maps/full-screen-location-picker.tsx @@ -9,9 +9,10 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; import { useDepartmentMapCenter } from '@/lib/map-center'; +import { useDepartmentMapStyle } from '@/lib/map-style'; +import { useMapboxAccessToken } from '@/lib/mapbox-token'; import { locationService } from '@/services/location'; import { useLocationStore } from '@/stores/app/location-store'; @@ -21,7 +22,7 @@ import { useLocationStore } from '@/stores/app/location-store'; * A full-screen location picker that allows users to select a location on a map. * * Debugging steps if component gets stuck in "Loading" state: - * 1. Check if Mapbox is configured (RESPOND_MAPBOX_PUBKEY in environment) + * 1. Check if Mapbox is configured (server-supplied token in config, or RESPOND_MAPBOX_PUBKEY in environment) * 2. Check device location permissions * 3. Check console logs for detailed debugging information * 4. Ensure device location services are enabled @@ -48,6 +49,7 @@ const FullScreenLocationPicker: React.FC = ({ ini // Read reactively: config often lands after this mounts, and a one-shot read would leave the map // parked on the bootstrap fallback for the rest of the session. const departmentMapCenter = useDepartmentMapCenter(); + const mapStyle = useDepartmentMapStyle(); const latitude = useLocationStore((state) => state.latitude); const longitude = useLocationStore((state) => state.longitude); const setLocation = useLocationStore((state) => state.setLocation); @@ -63,8 +65,8 @@ const FullScreenLocationPicker: React.FC = ({ ini const [isMapReady, setIsMapReady] = useState(false); const isMountedRef = useRef(true); - // Check if Mapbox is properly configured - const isMapboxConfigured = Boolean(Env.RESPOND_MAPBOX_PUBKEY && Env.RESPOND_MAPBOX_PUBKEY.trim() !== ''); + // Check if Mapbox is properly configured (server-supplied token, else the built-in one) + const isMapboxConfigured = Boolean(useMapboxAccessToken()); // Helper function to get current location from device const getCurrentLocationFromDevice = React.useCallback(async () => { @@ -377,6 +379,7 @@ const FullScreenLocationPicker: React.FC = ({ ini {currentLocation ? ( = ({ ini // Default map view centered on the department when no location is available = ({ isOpen, onClose, latitude, longitude, address, zoom = 15, showUserLocation = false, polygon, accentColor = '#ef4444' }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); + const mapStyle = useDepartmentMapStyle(); - const isMapboxConfigured = Boolean(Env.RESPOND_MAPBOX_PUBKEY && Env.RESPOND_MAPBOX_PUBKEY.trim() !== ''); + // Server-supplied token, else the built-in one + const isMapboxConfigured = Boolean(useMapboxAccessToken()); const polygonShape = React.useMemo(() => { if (!polygon || polygon.length < 3) { @@ -80,7 +83,7 @@ const FullScreenMapModal: React.FC = ({ isOpen, onClose {isMapboxConfigured ? ( - + {polygonBounds ? : } {polygonShape ? ( diff --git a/src/components/maps/location-picker.tsx b/src/components/maps/location-picker.tsx index b7146748..0bc3ae36 100644 --- a/src/components/maps/location-picker.tsx +++ b/src/components/maps/location-picker.tsx @@ -8,8 +8,9 @@ import { StyleSheet, TouchableOpacity } from 'react-native'; import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { Env } from '@/lib/env'; import { useDepartmentMapCenter } from '@/lib/map-center'; +import { useDepartmentMapStyle } from '@/lib/map-style'; +import { useMapboxAccessToken } from '@/lib/mapbox-token'; interface LocationPickerProps { initialLocation?: @@ -30,6 +31,7 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca // Read reactively: config often lands after this mounts, and a one-shot read would leave the map // parked on the bootstrap fallback for the rest of the session. const departmentMapCenter = useDepartmentMapCenter(); + const mapStyle = useDepartmentMapStyle(); const [currentLocation, setCurrentLocation] = useState<{ latitude: number; longitude: number; @@ -38,8 +40,8 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca const [mapError, setMapError] = useState(null); const [hasAttemptedLocationFetch, setHasAttemptedLocationFetch] = useState(false); - // Check if Mapbox is properly configured - const isMapboxConfigured = Boolean(Env.RESPOND_MAPBOX_PUBKEY && Env.RESPOND_MAPBOX_PUBKEY.trim() !== ''); + // Check if Mapbox is properly configured (server-supplied token, else the built-in one) + const isMapboxConfigured = Boolean(useMapboxAccessToken()); useEffect(() => { if (!isMapboxConfigured) { @@ -142,6 +144,7 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca {currentLocation ? ( = ({ initialLocation, onLoca // Default map view centered on the department until the user has a location = ({ focusedPoi }) => { const heading = useLocationStore((state) => state.heading); const isMapLocked = useLocationStore((state) => state.isMapLocked); - const mapOptions = useMemo(() => { - return Object.keys(Mapbox.StyleURL) - .map((key) => ({ - label: key, - data: (Mapbox.StyleURL as Record)[key], - })) - .sort(onSortOptions); - }, []); - - const styleURL = mapOptions[0]?.data; + const styleURL = useDepartmentMapStyle(); const pulseAnim = useRef(new Animated.Value(1)).current; const isFollowingUser = isScreenFocused && isMapLocked && focusedPoi == null; const isInteractionLocked = isMapLocked && focusedPoi == null; diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx index 67fb8b0a..80e01fbe 100644 --- a/src/components/maps/static-map.tsx +++ b/src/components/maps/static-map.tsx @@ -5,6 +5,7 @@ import { Pressable, StyleSheet } from 'react-native'; import { Box } from '@/components/ui/box'; import { Text } from '@/components/ui/text'; +import { useDepartmentMapStyle } from '@/lib/map-style'; interface StaticMapProps { latitude: number; @@ -18,6 +19,7 @@ interface StaticMapProps { const StaticMap: React.FC = ({ latitude, longitude, address, zoom = 15, height = 200, showUserLocation = false, onPress }) => { const { t } = useTranslation(); + const mapStyle = useDepartmentMapStyle(); if (!latitude || !longitude) { return ( @@ -30,7 +32,7 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo {/* Locked preview: all gestures disabled — the map must not fight the parent ScrollView. Interaction happens in the full-screen modal opened via onPress. */} - + {/* Marker for the location — an empty child renders an invisible annotation */} diff --git a/src/components/maps/static-map.web.tsx b/src/components/maps/static-map.web.tsx index 0d06d34c..56783771 100644 --- a/src/components/maps/static-map.web.tsx +++ b/src/components/maps/static-map.web.tsx @@ -5,6 +5,7 @@ import { Pressable, StyleSheet } from 'react-native'; import { Box } from '@/components/ui/box'; import { Text } from '@/components/ui/text'; +import { useDepartmentMapStyle } from '@/lib/map-style'; interface StaticMapProps { latitude: number; @@ -18,6 +19,7 @@ interface StaticMapProps { const StaticMap: React.FC = ({ latitude, longitude, address, zoom = 15, height = 200, showUserLocation = false, onPress }) => { const { t } = useTranslation(); + const mapStyle = useDepartmentMapStyle(); const [userCoordinate, setUserCoordinate] = useState<[number, number] | null>(null); useEffect(() => { @@ -52,7 +54,9 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo return (
- + {/* The web MapView reads styleURL only when it mounts, so key on it to rebuild when config + lands or the theme flips. */} +
({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/api/mfa/account-security'); +jest.mock('@/lib/mfa/passkey', () => ({ getPasskeyAssertion: jest.fn(), passkeysSupported: jest.fn(() => true) })); +jest.mock('@/lib/mfa/sso-browser', () => ({ runSsoRoundTrip: jest.fn() })); +jest.mock('@/components/ui/modal', () => { + const { View } = require('react-native'); + return { + Modal: ({ isOpen, children, ...props }: any) => (isOpen ? {children} : null), + ModalBackdrop: () => null, + ModalBody: ({ children }: any) => {children}, + ModalContent: ({ children }: any) => {children}, + ModalFooter: ({ children }: any) => {children}, + ModalHeader: ({ children }: any) => {children}, + }; +}); + +const mocked = api as jest.Mocked; +const assertion = getPasskeyAssertion as jest.Mock; +const options = (requestId: string): api.StepUpOptionsData => ({ + Methods: ['totp', 'passkey'], + Preferred: 'passkey', + EnrollmentRequired: false, + WindowMinutes: 5, + Passkey: { RequestId: requestId, Options: { challenge: requestId } }, +}); + +describe('AccountVerifyModal: a fresh second factor before an account change', () => { + beforeEach(() => jest.clearAllMocks()); + + it("verifies with this app's passkey on the ceremony the options started", async () => { + mocked.getStepUpOptions.mockResolvedValue(options('req-1')); + assertion.mockResolvedValue({ id: 'cred' }); + mocked.verifyStepUp.mockResolvedValue({ VerifiedAt: 'now', ExpiresAt: 'later' }); + const onDone = jest.fn(); + + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('account-verify-passkey')).toBeTruthy()); + await act(async () => fireEvent.press(getByTestId('account-verify-passkey'))); + + expect(assertion).toHaveBeenCalledWith({ challenge: 'req-1' }); + expect(mocked.verifyStepUp).toHaveBeenCalledWith({ Operation: 'account_security', Method: 'passkey', RequestId: 'req-1', Credential: { id: 'cred' } }); + expect(onDone).toHaveBeenCalledWith(true); + expect(mocked.getStepUpOptions).toHaveBeenCalledTimes(1); + }); + + it('asks for a fresh ceremony on a retry: the first one is spent whatever happened', async () => { + mocked.getStepUpOptions.mockResolvedValueOnce(options('req-1')).mockResolvedValueOnce(options('req-2')); + assertion.mockRejectedValueOnce(new PasskeyCeremonyError('failed')).mockResolvedValueOnce({ id: 'cred' }); + mocked.verifyStepUp.mockResolvedValue({ VerifiedAt: 'now', ExpiresAt: 'later' }); + const onDone = jest.fn(); + + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('account-verify-passkey')).toBeTruthy()); + await act(async () => fireEvent.press(getByTestId('account-verify-passkey'))); + expect(getByTestId('account-verify-error')).toBeTruthy(); + expect(onDone).not.toHaveBeenCalled(); + + await act(async () => fireEvent.press(getByTestId('account-verify-passkey'))); + expect(mocked.getStepUpOptions).toHaveBeenCalledTimes(2); + expect(mocked.verifyStepUp).toHaveBeenCalledWith(expect.objectContaining({ Method: 'passkey', RequestId: 'req-2' })); + expect(onDone).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/components/mfa/account-verify-modal.tsx b/src/components/mfa/account-verify-modal.tsx new file mode 100644 index 00000000..9bd1cb63 --- /dev/null +++ b/src/components/mfa/account-verify-modal.tsx @@ -0,0 +1,161 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { beginSsoReauthentication, getStepUpOptions, reauthenticateWithPassword, redeemSsoReauthentication, type StepUpOptionsData, verifyStepUp } from '@/api/mfa/account-security'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { Input, InputField } from '@/components/ui/input'; +import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { toMfaProblem } from '@/lib/mfa/errors'; +import { mfaErrorKey } from '@/lib/mfa/messages'; +import { getPasskeyAssertion, passkeysSupported } from '@/lib/mfa/passkey'; +import { isPasskeyCeremonyError } from '@/lib/mfa/passkey-errors'; +import { runSsoRoundTrip } from '@/lib/mfa/sso-browser'; + +/** What the server asked for before a change: the first factor again, or a fresh second factor for account security. */ +export type AccountProof = 'reauthenticate' | 'step_up'; + +interface AccountVerifyModalProps { + proof: AccountProof | null; + onDone: (verified: boolean) => void; +} + +const STEP_UP_OPERATION = 'account_security'; + +/** + * The fresh proof the server asks for before a sign-in method changes (passkey plan sections 6.2 and 7.6 row 14): the + * password (or the department's identity provider, for a member who signs in there), or a code or passkey for this app. + * Never approval or the provider's MFA for account changes. Secrets live only in local state. + */ +export const AccountVerifyModal: React.FC = ({ proof, onDone }) => { + const { t } = useTranslation(); + const [secret, setSecret] = useState(''); + const [busy, setBusy] = useState(false); + const [errorCode, setErrorCode] = useState(null); + const [useProvider, setUseProvider] = useState(false); + const [options, setOptions] = useState(null); + + useEffect(() => { + setSecret(''); + setErrorCode(null); + setUseProvider(false); + setOptions(null); + if (proof === 'step_up') { + void getStepUpOptions(STEP_UP_OPERATION) + .then(setOptions) + .catch((error: unknown) => setErrorCode(toMfaProblem(error).code)); + } + }, [proof]); + + const attempt = useCallback( + async (action: () => Promise) => { + setBusy(true); + setErrorCode(null); + try { + await action(); + onDone(true); + } catch (error) { + const code = isPasskeyCeremonyError(error) ? `passkey_${error.reason}` : toMfaProblem(error).code; + if (code === 'sso_reauthentication_required') { + setUseProvider(true); + } + setErrorCode(code); + } finally { + setSecret(''); + setBusy(false); + } + }, + [onDone] + ); + + const submitPassword = () => void attempt(() => reauthenticateWithPassword(secret)); + + const submitProvider = () => + void attempt(async () => { + const trip = await runSsoRoundTrip((secrets) => beginSsoReauthentication(secrets)); + if (!trip.ok) { + throw Object.assign(new Error('sso'), { response: { status: 400, data: { type: trip.code ?? `sso_${trip.reason}` } } }); + } + await redeemSsoReauthentication(trip.trip.ssoTransactionId, trip.trip.ssoCode, trip.trip.codeVerifier); + }); + + const submitCode = () => void attempt(() => verifyStepUp({ Operation: STEP_UP_OPERATION, Method: 'totp', Code: secret.trim() })); + + const submitPasskey = () => + void attempt(async () => { + // The ceremony the options started answers one attempt: whatever the outcome, a retry asks for a fresh one. + const started = options?.Passkey ?? null; + if (started) { + setOptions((current) => (current ? { ...current, Passkey: null } : current)); + } + const ceremony = started ?? (await getStepUpOptions(STEP_UP_OPERATION)).Passkey; + if (!ceremony) { + throw Object.assign(new Error('passkey'), { response: { status: 400, data: { type: 'passkeys_unavailable' } } }); + } + const credential = await getPasskeyAssertion(ceremony.Options); + await verifyStepUp({ Operation: STEP_UP_OPERATION, Method: 'passkey', RequestId: ceremony.RequestId, Credential: credential }); + }); + + const reauth = proof === 'reauthenticate'; + const offersCode = !reauth && (options?.Methods ?? ['totp']).includes('totp'); + const offersPasskey = !reauth && (options?.Methods ?? []).includes('passkey') && passkeysSupported(); + + return ( + onDone(false)} testID="account-verify-modal"> + + + + {reauth ? t('mfa.account.reauth_title') : t('mfa.account.step_up_title')} + + + + {reauth ? (useProvider ? t('mfa.account.reauth_provider_body') : t('mfa.account.reauth_body')) : t('mfa.account.step_up_body')} + {(reauth && !useProvider) || offersCode ? ( + + + + ) : null} + {offersPasskey ? ( + + ) : null} + {reauth && useProvider ? ( + + ) : null} + {errorCode ? ( + + {t(mfaErrorKey(errorCode))} + + ) : null} + + + + + {(reauth && !useProvider) || offersCode ? ( + + ) : null} + + + + ); +}; diff --git a/src/components/mfa/approval-panel.tsx b/src/components/mfa/approval-panel.tsx new file mode 100644 index 00000000..426ed6b2 --- /dev/null +++ b/src/components/mfa/approval-panel.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; + +interface ApprovalPanelProps { + /** The two-digit number the member types in Responder; shown on this screen only. */ + matchNumber: string; + onCancel: () => void; +} + +/** + * "Approve with Responder" while it waits (passkey plan section 7.9): the number to type in Responder, and a way to stop + * waiting. The number is never pushed to Responder, so it only works for someone who can see this screen. + */ +export const ApprovalPanel: React.FC = ({ matchNumber, onCancel }) => { + const { t } = useTranslation(); + return ( + + + + {t('mfa.approval.number_label')} + + + {matchNumber} + + + {t('mfa.approval.waiting')} + + + + + ); +}; diff --git a/src/components/mfa/recovery-codes-modal.tsx b/src/components/mfa/recovery-codes-modal.tsx new file mode 100644 index 00000000..330265e9 --- /dev/null +++ b/src/components/mfa/recovery-codes-modal.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { RecoveryCodes } from '@/components/mfa/recovery-codes'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; +import useAuthStore from '@/stores/auth/store'; + +/** + * After an authenticator was set up during sign-in (passkey plan section 6.2): the new recovery codes, once. Mounted at + * the app shell so it shows on whatever screen the sign-in lands on. + */ +export const RecoveryCodesModal: React.FC = () => { + const { t } = useTranslation(); + const codes = useAuthStore((s) => s.pendingRecoveryCodes); + const dismiss = useAuthStore((s) => s.dismissRecoveryCodes); + + return ( + 0} onClose={dismiss} testID="mfa-recovery-codes-modal"> + + + + {t('mfa.recovery_codes.title')} + + {codes ? : null} + + + + + + ); +}; diff --git a/src/components/mfa/recovery-codes.tsx b/src/components/mfa/recovery-codes.tsx new file mode 100644 index 00000000..3668cdde --- /dev/null +++ b/src/components/mfa/recovery-codes.tsx @@ -0,0 +1,42 @@ +import * as Clipboard from 'expo-clipboard'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; + +interface RecoveryCodesProps { + codes: string[]; +} + +/** + * New recovery codes, shown once (passkey plan section 6.1). They are in memory only; the member copies or writes them + * down now, because nothing can show them again. + */ +export const RecoveryCodes: React.FC = ({ codes }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + const copy = useCallback(async () => { + await Clipboard.setStringAsync(codes.join('\n')); + setCopied(true); + }, [codes]); + + return ( + + {t('mfa.recovery_codes.body')} + + {codes.map((code) => ( + + {code} + + ))} + + + + ); +}; diff --git a/src/components/onboarding/__tests__/onboarding-screen.test.tsx b/src/components/onboarding/__tests__/onboarding-screen.test.tsx new file mode 100644 index 00000000..b3628878 --- /dev/null +++ b/src/components/onboarding/__tests__/onboarding-screen.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { ScrollView } from 'react-native'; + +import { OnboardingScreen } from '../onboarding-screen'; + +let mockWindow = { width: 320, height: 568, fontScale: 1, scale: 1 }; +jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }), cssInterop: jest.fn() })); +jest.mock('@/components/ui', () => { + const { View } = require('react-native'); + return { View, SafeAreaView: View, FocusAwareStatusBar: () => null }; +}); +jest.mock('@/components/ui/text', () => ({ Text: require('react-native').Text })); +jest.mock('@/components/ui/pressable', () => ({ Pressable: require('react-native').Pressable })); +jest.mock('lucide-react-native', () => ({ ChevronRight: () => null })); + +const props = { + title: 'Resgrid Responder', + description: 'Stay connected to your department.', + icon: null, + currentIndex: 0, + total: 3, + skipLabel: 'Skip', + nextLabel: 'Next', + finishLabel: 'Get started', + onSkip: jest.fn(), + onNext: jest.fn(), + onFinish: jest.fn(), + onSlideChange: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockWindow = { width: 320, height: 568, fontScale: 1, scale: 1 }; + jest.spyOn(require('react-native'), 'useWindowDimensions').mockImplementation(() => mockWindow); +}); + +it.each([ + [320, 568, 1, 'column'], + [844, 390, 1, 'row'], + [768, 1024, 1, 'row'], + [1024, 768, 2, 'column'], +] as const)('supports %s × %s with font scale %s', (width, height, fontScale, direction) => { + mockWindow = { width, height, fontScale, scale: 1 }; + const screen = render(); + expect(Object.assign({}, ...screen.getByTestId('onboarding-content').props.style.flat(Infinity)).flexDirection).toBe(direction); + const title = screen.UNSAFE_getAllByType(require('react-native').Text).find((node) => node.props.children === props.title)!; + expect(title.props.className).toContain(width < 360 ? 'leading-[36px]' : 'leading-[44px]'); + expect(title.props.numberOfLines).toBeUndefined(); + expect(screen.UNSAFE_getByType(ScrollView).props.contentContainerStyle.flexGrow).toBe(1); + fireEvent.press(screen.getByTestId('next-button')); + expect(props.onNext).toHaveBeenCalledTimes(1); + screen.unmount(); +}); + +it('reflows after rotation without resetting the selected step', () => { + const screen = render(); + mockWindow = { width: 844, height: 390, fontScale: 1, scale: 1 }; + screen.rerender(); + const safeArea = screen.UNSAFE_getAllByType(require('react-native').View).find((view) => view.props.onLayout); + fireEvent(safeArea!, 'layout', { nativeEvent: { layout: { width: 844 } } }); + expect(Object.assign({}, ...screen.getByTestId('onboarding-content').props.style.flat(Infinity)).flexDirection).toBe('row'); + expect(screen.getByRole('progressbar').props.accessibilityValue.now).toBe(2); + screen.unmount(); +}); + +it('ignores vertical scrolling and clamps swipes at the last step without completing', () => { + const screen = render(); + const content = screen.getByTestId('onboarding-flatlist'); + fireEvent(content, 'touchStart', { nativeEvent: { pageX: 280, pageY: 100 } }); + fireEvent(content, 'touchEnd', { nativeEvent: { pageX: 180, pageY: 400 } }); + expect(props.onSlideChange).not.toHaveBeenCalled(); + fireEvent(content, 'touchStart', { nativeEvent: { pageX: 280, pageY: 100 } }); + fireEvent(content, 'touchEnd', { nativeEvent: { pageX: 80, pageY: 110 } }); + expect(props.onSlideChange).toHaveBeenCalledWith(2); + expect(props.onFinish).not.toHaveBeenCalled(); + fireEvent.press(screen.getByTestId('get-started-button')); + expect(props.onFinish).toHaveBeenCalledTimes(1); + screen.unmount(); +}); + +it('starts each step at the top of the page', () => { + const scrollTo = jest.fn(); + const screen = render(, { createNodeMock: (element) => (element.type === 'RNScrollView' ? { scrollTo } : null) }); + scrollTo.mockClear(); + screen.rerender(); + expect(scrollTo).toHaveBeenCalledWith({ y: 0, animated: false }); + scrollTo.mockClear(); + screen.rerender(); + expect(scrollTo).not.toHaveBeenCalled(); + screen.unmount(); +}); diff --git a/src/components/onboarding/onboarding-screen.tsx b/src/components/onboarding/onboarding-screen.tsx new file mode 100644 index 00000000..86adcddc --- /dev/null +++ b/src/components/onboarding/onboarding-screen.tsx @@ -0,0 +1,152 @@ +import { ChevronRight } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useEffect, useRef, useState } from 'react'; +import type { GestureResponderEvent } from 'react-native'; +import { Image, ScrollView, StyleSheet, useWindowDimensions } from 'react-native'; + +import { FocusAwareStatusBar, SafeAreaView, View } from '@/components/ui'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; + +interface OnboardingScreenProps { + title: string; + description: string; + icon: React.ReactNode; + currentIndex: number; + total: number; + skipLabel: string; + nextLabel: string; + finishLabel: string; + onSkip: () => void; + onNext: () => void; + onFinish: () => void; + onSlideChange: (index: number) => void; +} + +/** Content grows with text size; the whole page remains scrollable on short screens. */ +export const OnboardingScreen: React.FC = ({ title, description, icon, currentIndex, total, skipLabel, nextLabel, finishLabel, onSkip, onNext, onFinish, onSlideChange }) => { + const { width, height, fontScale } = useWindowDimensions(); + const [containerWidth, setContainerWidth] = useState(width); + const { colorScheme } = useColorScheme(); + const start = useRef<{ x: number; y: number } | null>(null); + const scroll = useRef(null); + const availableWidth = Math.min(width, containerWidth); + const horizontal = availableWidth >= 680 && fontScale < 1.5; + const compact = height < 500; + const last = currentIndex === total - 1; + const actionColor = colorScheme === 'dark' ? 'black' : 'white'; + + // The page stays mounted between steps (so focus stays on Next); each step starts at its top, not where the last one was scrolled to. + useEffect(() => { + scroll.current?.scrollTo({ y: 0, animated: false }); + }, [currentIndex]); + + const handleTouchStart = (event: GestureResponderEvent) => { + start.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY ?? 0 }; + }; + const handleTouchEnd = (event: GestureResponderEvent) => { + const origin = start.current; + start.current = null; + if (!origin) return; + const dx = event.nativeEvent.pageX - origin.x; + const dy = (event.nativeEvent.pageY ?? 0) - origin.y; + // Vertical scrolling must not accidentally advance or complete onboarding. + if (Math.abs(dx) < 60 || Math.abs(dx) <= Math.abs(dy) * 1.5) return; + onSlideChange(Math.max(0, Math.min(total - 1, currentIndex + (dx < 0 ? 1 : -1)))); + }; + + return ( + setContainerWidth(event.nativeEvent.layout.width)}> + + ); +}; + +const styles = StyleSheet.create({ + scroll: { flexGrow: 1 }, + page: { flexGrow: 1, width: '100%', maxWidth: 960, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 16 }, + logo: { width: 136, height: 36, flexShrink: 1 }, + skip: { minHeight: 48, justifyContent: 'center', paddingHorizontal: 12, flexShrink: 1 }, + main: { flexGrow: 1, justifyContent: 'center' }, + card: { borderRadius: 28, padding: 24, gap: 28, alignItems: 'center', width: '100%' }, + horizontal: { flexDirection: 'row', padding: 32, gap: 40 }, + vertical: { flexDirection: 'column' }, + artwork: { borderRadius: 999, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, + icon: { width: 104, height: 104, borderRadius: 32, justifyContent: 'center', alignItems: 'center' }, + copy: { flexShrink: 1, minWidth: 0, width: '100%', gap: 16 }, + // Explicit line heights override the Text component's default body line height. + title: { alignSelf: 'stretch', paddingVertical: 4 }, + description: { alignSelf: 'stretch' }, + footer: { width: '100%', maxWidth: 480, alignSelf: 'center', gap: 24 }, + progress: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 8, minHeight: 16 }, + dot: { height: 8, borderRadius: 4 }, + action: { minHeight: 56, borderRadius: 16, paddingVertical: 16, paddingHorizontal: 24, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 8 }, +}); diff --git a/src/components/operations/__tests__/time-report-picker.test.tsx b/src/components/operations/__tests__/time-report-picker.test.tsx new file mode 100644 index 00000000..d4936a64 --- /dev/null +++ b/src/components/operations/__tests__/time-report-picker.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +import { TimeReportEditor } from '@/components/operations/time-report-editor'; +import { newEntry } from '@/lib/operations/time'; +import { type TimeReport, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/components/operations/option-select', () => ({ OptionSelect: () => null })); +jest.mock('react-native-safe-area-context', () => ({ SafeAreaView: require('react-native').View })); + +const subject = { type: TimeSubjectType.Personnel, id: 'person-1', label: 'Crew member' }; +const props = () => ({ + dateKey: '2026-09-26', + report: { CanAct: true, Status: TimeReportStatus.Draft } as TimeReport, + entries: [newEntry(subject, '2026-09-26', 0, { StartLocal: '2026-09-26T22:00:00', EndLocal: '2026-09-26T23:00:00' })], + subjects: [subject], + names: { 'person-1': 'Crew member' }, + scopeLabel: 'Crew member', + isCrew: false, + coveredBy: null, + issues: [], + warnings: [], + canCreate: true, + canApprove: false, + canWrite: () => true, + dirty: false, + busy: false, + onStart: jest.fn(), + onChange: jest.fn(), + onSave: jest.fn(), + onSubmit: jest.fn(), + onSign: jest.fn(), + onApprove: jest.fn(), +}); + +it('keeps overnight end times on the next day after picking a clock time', () => { + const options = props(); + const screen = render(); + fireEvent.press(screen.getByTestId('operations-entry-end-0')); + fireEvent.press(screen.getByTestId('operations-entry-end-0-hour-2')); + fireEvent.press(screen.getByTestId('operations-entry-end-0-minute-30')); + fireEvent.press(screen.getByTestId('operations-entry-end-0-done')); + expect(options.onChange).toHaveBeenCalledWith([expect.objectContaining({ StartLocal: '2026-09-26T22:00:00', EndLocal: '2026-09-27T02:30' })]); + screen.unmount(); +}); + +it('does not allow time edits on a submitted report', () => { + const options = props(); + options.report.Status = TimeReportStatus.Submitted; + const screen = render(); + fireEvent.press(screen.getByTestId('operations-entry-start-0')); + expect(screen.queryByTestId('operations-entry-start-0-done')).toBeNull(); + expect(options.onChange).not.toHaveBeenCalled(); + screen.unmount(); +}); diff --git a/src/components/operations/time-report-editor.tsx b/src/components/operations/time-report-editor.tsx index bed50fc4..a20c0cbd 100644 --- a/src/components/operations/time-report-editor.tsx +++ b/src/components/operations/time-report-editor.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { DateTimeField } from '@/components/common/date-time-field'; import { OptionSelect } from '@/components/operations/option-select'; import { Button, ButtonText } from '@/components/ui/button'; import { HStack } from '@/components/ui/hstack'; @@ -9,7 +10,7 @@ import { Switch } from '@/components/ui/switch'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import type { EntrySubject } from '@/lib/operations/time'; -import { applyToCrew, combineDateTime, dateOf, endLocalFor, entryHours, hoursBySubject, isClockTime, isReportEditable, newEntry, subjectIdOf, sumHours, timeOf } from '@/lib/operations/time'; +import { applyToCrew, combineDateTime, dateOf, endLocalFor, entryHours, hoursBySubject, isReportEditable, newEntry, subjectIdOf, sumHours, timeOf } from '@/lib/operations/time'; import type { TimeEntry, TimeReport, TimeReportIssue } from '@/models/v4/operations'; import { TimeEntryType, TimeReportScope, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations'; @@ -41,31 +42,6 @@ interface TimeReportEditorProps { onApprove: () => void; } -// Keeps the keystrokes local and only commits a complete HH:mm, so a half-typed time never lands -// in the entry as an unparseable value. -const ClockField = ({ value, onCommit, isDisabled, testID, label }: { value: string; onCommit: (clock: string) => void; isDisabled: boolean; testID: string; label: string }) => { - const [text, setText] = useState(value); - useEffect(() => { - setText(value); - }, [value]); - return ( - - { - setText(next); - if (isClockTime(next)) onCommit(next); - }} - /> - - ); -}; - const typeRank = (entry: TimeEntry) => (entry.SubjectType === TimeSubjectType.Unit ? 0 : entry.SubjectType === TimeSubjectType.Personnel ? 1 : 2); // A crew or individual time report for one day (the paper CTR / OF-288 analog). On a crew report the unit's @@ -185,13 +161,23 @@ export const TimeReportEditor = ({ /> {t('operations.time.from')} - setStart(index, clock)} isDisabled={!rowEditable} testID={`operations-entry-start-${index}`} /> + setStart(index, clock)} + disabled={!rowEditable} + testID={`operations-entry-start-${index}`} + /> {t('operations.time.to')} - update(index, { EndLocal: endLocalFor(entry.StartLocal, clock) })} - isDisabled={!rowEditable} + onChange={(clock) => update(index, { EndLocal: endLocalFor(entry.StartLocal, clock) })} + disabled={!rowEditable} testID={`operations-entry-end-${index}`} /> diff --git a/src/components/records/__tests__/record-date-picker.test.tsx b/src/components/records/__tests__/record-date-picker.test.tsx new file mode 100644 index 00000000..42a67754 --- /dev/null +++ b/src/components/records/__tests__/record-date-picker.test.tsx @@ -0,0 +1,22 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { TextInput } from 'react-native'; + +import { RecordField } from '@/components/records/record-field'; +import { RmsFieldClassification, RmsFieldType } from '@/models/v4/records'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('react-native-safe-area-context', () => ({ SafeAreaView: require('react-native').View })); + +it.each([RmsFieldType.Date, RmsFieldType.DateTime])('renders record field type %i as a picker and preserves its value', (type) => { + const onChange = jest.fn(); + const value = type === RmsFieldType.Date ? '2024-02-29' : '2024-02-29T21:30:00.000Z'; + const screen = render( + + ); + expect(screen.UNSAFE_queryAllByType(TextInput)).toHaveLength(0); + fireEvent.press(screen.getByTestId('record-date-picker')); + fireEvent.press(screen.getByTestId('record-date-picker-done')); + expect(onChange).toHaveBeenCalledWith({ Value: value }); + screen.unmount(); +}); diff --git a/src/components/records/record-field.tsx b/src/components/records/record-field.tsx index 9a4436af..a2de482d 100644 --- a/src/components/records/record-field.tsx +++ b/src/components/records/record-field.tsx @@ -2,6 +2,7 @@ import { Lock } from 'lucide-react-native'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { DateTimeField } from '@/components/common/date-time-field'; import { Badge, BadgeText } from '@/components/ui/badge'; import { Box } from '@/components/ui/box'; import { Checkbox, CheckboxIcon, CheckboxIndicator, CheckboxLabel } from '@/components/ui/checkbox'; @@ -77,6 +78,10 @@ export const RecordField: React.FC = ({ field, value, required } switch (field.Type) { + case RmsFieldType.Date: + case RmsFieldType.DateTime: + return wrap(); + case RmsFieldType.LongText: return wrap(