diff --git a/__mocks__/expo-application.ts b/__mocks__/expo-application.ts new file mode 100644 index 00000000..5f03d524 --- /dev/null +++ b/__mocks__/expo-application.ts @@ -0,0 +1,9 @@ +// Jest stand-in for expo-application, whose native module cannot load under Jest. The values are what the app header +// code reads (client-app.ts); tests that need others mock expo-application themselves, which takes precedence. +export const applicationName = 'Resgrid Unit'; +export const applicationId = 'com.resgrid.unit'; +export const nativeApplicationVersion = '1.0.0'; +export const nativeBuildVersion = '1'; +export const getInstallationTimeAsync = jest.fn(async () => new Date(0)); +export const getAndroidId = jest.fn(() => 'test-android-id'); +export const getIosIdForVendorAsync = jest.fn(async () => 'test-idfv'); 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 cc5c879c..1c19441a 100644 --- a/app.config.ts +++ b/app.config.ts @@ -53,6 +53,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ supportsTablet: true, bundleIdentifier: Env.BUNDLE_ID, requireFullScreen: true, + // Passkeys: Unit'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:unit.resgrid.com', 'webcredentials:unit-eu-central.resgrid.com'], + }), infoPlist: { UIBackgroundModes: ['remote-notification', 'audio', 'bluetooth-central', 'voip'], ITSAppUsesNonExemptEncryption: false, diff --git a/electron-builder.config.js b/electron-builder.config.js index e3bdfebf..ac25ccff 100644 --- a/electron-builder.config.js +++ b/electron-builder.config.js @@ -12,7 +12,18 @@ module.exports = { buildResources: 'assets', }, - files: ['dist/**/*', 'electron/**/*', '!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}', '!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}', '!**/node_modules/.bin'], + files: [ + 'dist/**/*', + 'electron/**/*', + '!electron/__tests__/**', + '!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}', + '!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}', + '!**/node_modules/.bin', + ], + + // This app's scheme (the macOS Info.plist and the Linux desktop entry): legacy SSO returns to resgridunit://auth/callback. + // macOS hands a packaged app only the schemes declared here. + protocols: [{ name: 'Resgrid Unit', schemes: ['resgridunit'] }], // macOS configuration mac: { diff --git a/electron/__tests__/legacy-sso.test.ts b/electron/__tests__/legacy-sso.test.ts new file mode 100644 index 00000000..a3655a62 --- /dev/null +++ b/electron/__tests__/legacy-sso.test.ts @@ -0,0 +1,310 @@ +/** + * Legacy SSO in the desktop app (electron/legacy-sso.js): the OIDC code flow run as a native client (PKCE and state, the + * provider in the member's browser, the code redeemed in the main process), the SAML start page, and the return on this + * app's own scheme, which only a waiting sign-in with the same state or RelayState takes. + * + * @jest-environment node + */ +import crypto from 'crypto'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { createLegacySso, registerLegacySso } = require('../legacy-sso'); + +const AUTHORITY = 'https://login.example-idp.com/tenant'; +const DISCOVERY = `${AUTHORITY}/.well-known/openid-configuration`; +const AUTHORIZE = 'https://login.example-idp.com/tenant/oauth2/authorize'; +const TOKEN = 'https://login.example-idp.com/tenant/oauth2/token'; +const START = 'https://api.resgrid.com/api/v4/connect/saml-mobile-login?departmentToken=enc&RelayState=unit.3f2b8c1e-5d7a-4e9b-8a61-0c4d2e7f9b13'; + +const json = (body: unknown, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body }); +const base64Url = (bytes: Buffer) => bytes.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +interface Fixture { + sso: ReturnType; + opened: string[]; + requests: { url: string; init?: { method?: string; headers?: Record; body?: string } }[]; + focus: jest.Mock; +} + +const fixture = (overrides: { tokenAnswer?: unknown; discovery?: unknown; waitMs?: number; openExternal?: (url: string) => Promise } = {}): Fixture => { + const opened: string[] = []; + const requests: Fixture['requests'] = []; + const focus = jest.fn(); + const fetch = jest.fn(async (url: string, init?: Fixture['requests'][number]['init']) => { + requests.push({ url, init }); + if (url === DISCOVERY) { + return 'discovery' in overrides ? overrides.discovery : json({ issuer: AUTHORITY, authorization_endpoint: AUTHORIZE, token_endpoint: TOKEN }); + } + if (url === TOKEN) { + return 'tokenAnswer' in overrides ? overrides.tokenAnswer : json({ id_token: 'idp.id.token', access_token: 'at', token_type: 'Bearer' }); + } + return json({}, 404); + }); + const openExternal = + overrides.openExternal ?? + (async (url: string) => { + opened.push(url); + }); + const sso = createLegacySso({ scheme: 'resgridunit', openExternal, fetch, focus, ...(overrides.waitMs ? { waitMs: overrides.waitMs } : {}) }); + return { sso, opened, requests, focus }; +}; + +/** Starts an OIDC sign-in and waits until the provider page is open; returns the pending answer and the page. */ +const startOidc = async (f: Fixture) => { + const answer = f.sso.oidc(AUTHORITY, 'resgrid-desktop'); + for (let i = 0; i < 10 && f.opened.length === 0; i++) { + await tick(); + } + return { answer, page: new URL(f.opened[f.opened.length - 1]) }; +}; + +describe('createLegacySso: OIDC', () => { + it("runs the code flow with PKCE in the member's browser and redeems the code here, on the app's registered redirect URI", async () => { + const f = fixture(); + const { answer, page } = await startOidc(f); + + expect(`${page.origin}${page.pathname}`).toBe(AUTHORIZE); + expect(Object.fromEntries(page.searchParams)).toEqual({ + response_type: 'code', + client_id: 'resgrid-desktop', + redirect_uri: 'resgridunit://auth/callback', + scope: 'openid email profile offline_access', + state: expect.stringMatching(/^[A-Za-z0-9_-]{32}$/), + code_challenge: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + code_challenge_method: 'S256', + }); + expect(f.sso.redirectUri).toBe('resgridunit://auth/callback'); + + expect(f.sso.handleLink(`resgridunit://auth/callback?code=the-code&state=${page.searchParams.get('state')}`)).toBe(true); + await expect(answer).resolves.toEqual({ ok: true, idToken: 'idp.id.token' }); + expect(f.focus).toHaveBeenCalledTimes(1); + + const redemption = f.requests.find((r) => r.url === TOKEN)!; + expect(redemption.init?.method).toBe('POST'); + expect(redemption.init?.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }); + const form = Object.fromEntries(new URLSearchParams(redemption.init?.body)); + expect(form).toEqual({ grant_type: 'authorization_code', code: 'the-code', redirect_uri: 'resgridunit://auth/callback', client_id: 'resgrid-desktop', code_verifier: expect.any(String) }); + // The verifier the provider receives is the one the challenge was made from. + expect(base64Url(crypto.createHash('sha256').update(form.code_verifier).digest())).toBe(page.searchParams.get('code_challenge')); + expect(f.sso.waiting).toBe(false); + }); + + it('asks the provider to authenticate the member again for a shared installation, and only then', async () => { + const shared = fixture(); + const answer = shared.sso.oidc(AUTHORITY, 'resgrid-desktop', true); + for (let i = 0; i < 10 && shared.opened.length === 0; i++) { + await tick(); + } + const page = new URL(shared.opened[0]); + expect(page.searchParams.get('prompt')).toBe('login'); + expect(page.searchParams.get('max_age')).toBe('0'); + shared.sso.cancel(); + await answer; + + const personal = fixture(); + const { answer: personalAnswer, page: personalPage } = await startOidc(personal); + expect(personalPage.searchParams.has('prompt')).toBe(false); + expect(personalPage.searchParams.has('max_age')).toBe(false); + personal.sso.cancel(); + await personalAnswer; + }); + + it('ignores a link with another state, or no state, and keeps waiting for its own', async () => { + const f = fixture(); + const { answer, page } = await startOidc(f); + + expect(f.sso.handleLink('resgridunit://auth/callback?code=forged&state=someone-elses')).toBe(true); + expect(f.sso.handleLink('resgridunit://auth/callback?code=forged')).toBe(true); + expect(f.sso.waiting).toBe(true); + expect(f.focus).not.toHaveBeenCalled(); + + f.sso.handleLink(`resgridunit://auth/callback?code=real&state=${page.searchParams.get('state')}`); + await expect(answer).resolves.toEqual({ ok: true, idToken: 'idp.id.token' }); + expect(new URLSearchParams(f.requests.find((r) => r.url === TOKEN)!.init?.body).get('code')).toBe('real'); + }); + + it("reports the provider's refusal: a denial, or another error with its code", async () => { + const denied = fixture(); + const first = await startOidc(denied); + denied.sso.handleLink(`resgridunit://auth/callback?error=access_denied&state=${first.page.searchParams.get('state')}`); + await expect(first.answer).resolves.toEqual({ ok: false, reason: 'denied', code: 'access_denied' }); + + const broken = fixture(); + const second = await startOidc(broken); + broken.sso.handleLink(`resgridunit://auth/callback?error=invalid_scope&state=${second.page.searchParams.get('state')}`); + await expect(second.answer).resolves.toEqual({ ok: false, reason: 'failed', code: 'invalid_scope' }); + expect(broken.requests.some((r) => r.url === TOKEN)).toBe(false); + }); + + it('fails when the return has no code, or the provider redeems it without an id_token or at all', async () => { + const noCode = fixture(); + const a = await startOidc(noCode); + noCode.sso.handleLink(`resgridunit://auth/callback?state=${a.page.searchParams.get('state')}`); + await expect(a.answer).resolves.toEqual({ ok: false, reason: 'failed' }); + + for (const tokenAnswer of [json({ access_token: 'at' }), json({ error: 'invalid_grant' }, 400), json({ id_token: '' }), json({ id_token: 'idp.id.token' }, 500)]) { + const f = fixture({ tokenAnswer }); + const { answer, page } = await startOidc(f); + f.sso.handleLink(`resgridunit://auth/callback?code=c&state=${page.searchParams.get('state')}`); + await expect(answer).resolves.toEqual({ ok: false, reason: 'failed' }); + } + }); + + it('opens nothing for an authority or provider pages that are not https, a missing client id, or a discovery failure', async () => { + const cases: [string, string, unknown][] = [ + ['http://login.example-idp.com/tenant', 'c', undefined], + ['javascript:alert(1)', 'c', undefined], + ['not a url', 'c', undefined], + [AUTHORITY, '', undefined], + [AUTHORITY, 'c', json({}, 500)], + [AUTHORITY, 'c', json({ authorization_endpoint: 'http://login.example-idp.com/authorize', token_endpoint: TOKEN })], + [AUTHORITY, 'c', json({ authorization_endpoint: AUTHORIZE, token_endpoint: 'http://login.example-idp.com/token' })], + [AUTHORITY, 'c', json({ authorization_endpoint: AUTHORIZE })], + // A provider page that answers with an error is no discovery document, whatever its body says. + [AUTHORITY, 'c', json({ authorization_endpoint: AUTHORIZE, token_endpoint: TOKEN }, 503)], + ]; + for (const [authority, clientId, discovery] of cases) { + const f = fixture(discovery === undefined ? {} : { discovery }); + await expect(f.sso.oidc(authority, clientId)).resolves.toEqual({ ok: false, reason: 'failed' }); + expect(f.opened).toEqual([]); + if (discovery === undefined) { + // Refused before anything is fetched: no discovery document is read from an address that is not https. + expect(f.requests).toEqual([]); + } + expect(f.sso.waiting).toBe(false); + } + }); + + it('reads the discovery document under the authority, whatever its trailing slash', async () => { + const f = fixture(); + const answer = f.sso.oidc(`${AUTHORITY}/`, 'c'); + await tick(); + expect(f.requests[0].url).toBe(DISCOVERY); + f.sso.cancel(); + await expect(answer).resolves.toEqual({ ok: false, reason: 'cancelled' }); + }); +}); + +describe('createLegacySso: SAML', () => { + it("opens the server's start page and answers with the relay's link back, for the page to check", async () => { + const f = fixture(); + const answer = f.sso.saml(START); + await tick(); + expect(f.opened).toEqual([START]); + + const relayed = 'resgridunit://auth/callback?saml_response=saml-relay%3Aabc&department_token=enc&relay_state=unit.3f2b8c1e-5d7a-4e9b-8a61-0c4d2e7f9b13'; + expect(f.sso.handleLink(relayed)).toBe(true); + await expect(answer).resolves.toEqual({ ok: true, url: relayed }); + expect(f.focus).toHaveBeenCalledTimes(1); + }); + + it('ignores a relay link with another RelayState or no SAML response', async () => { + const f = fixture(); + const answer = f.sso.saml(START); + await tick(); + + f.sso.handleLink('resgridunit://auth/callback?saml_response=x&relay_state=unit.someone-elses-nonce-0000'); + f.sso.handleLink('resgridunit://auth/callback?relay_state=unit.3f2b8c1e-5d7a-4e9b-8a61-0c4d2e7f9b13'); + expect(f.sso.waiting).toBe(true); + + f.sso.cancel(); + await expect(answer).resolves.toEqual({ ok: false, reason: 'cancelled' }); + }); + + it('opens no start page that is not https (loopback http aside) or that carries no RelayState', async () => { + for (const page of ['http://api.resgrid.com/api/v4/connect/saml-mobile-login?RelayState=unit.x', 'https://api.resgrid.com/api/v4/connect/saml-mobile-login', 'file:///etc/passwd?RelayState=x']) { + const f = fixture(); + await expect(f.sso.saml(page)).resolves.toEqual({ ok: false, reason: 'failed' }); + expect(f.opened).toEqual([]); + } + + const dev = fixture(); + const answer = dev.sso.saml('http://127.0.0.1:5098/api/v4/connect/saml-mobile-login?RelayState=unit.dev-nonce-0123456789'); + await tick(); + expect(dev.opened).toHaveLength(1); + dev.sso.cancel(); + await answer; + }); +}); + +describe('createLegacySso: one sign-in at a time', () => { + it('a new sign-in replaces the waiting one, and cancel or the time limit ends it', async () => { + const f = fixture(); + const first = f.sso.saml(START); + await tick(); + const second = f.sso.saml(START); + await expect(first).resolves.toEqual({ ok: false, reason: 'cancelled' }); + + f.sso.cancel(); + await expect(second).resolves.toEqual({ ok: false, reason: 'cancelled' }); + expect(f.sso.waiting).toBe(false); + + const timed = fixture({ waitMs: 20 }); + await expect(timed.sso.saml(START)).resolves.toEqual({ ok: false, reason: 'cancelled' }); + expect(timed.sso.waiting).toBe(false); + }); + + it('fails when the browser cannot be opened', async () => { + const f = fixture({ + openExternal: async () => { + throw new Error('no browser'); + }, + }); + await expect(f.sso.saml(START)).resolves.toEqual({ ok: false, reason: 'failed' }); + expect(f.sso.waiting).toBe(false); + }); + + it("takes only this app's sign-in return: other links are left to the caller, and a return with nothing waiting is dropped", () => { + const f = fixture(); + expect(f.sso.handleLink('resgridunit://auth/callback?code=x&state=y')).toBe(true); + expect(f.sso.handleLink('resgridunit://calls/42')).toBe(false); + expect(f.sso.handleLink('resgridunit://other/callback?code=x&state=y')).toBe(false); + expect(f.sso.handleLink('resgridunit://auth/other?code=x&state=y')).toBe(false); + expect(f.sso.handleLink('resgriddispatch://auth/callback?code=x&state=y')).toBe(false); + expect(f.sso.handleLink('https://resgrid.com/auth/callback?code=x')).toBe(false); + expect(f.sso.handleLink('not a link')).toBe(false); + expect(f.focus).not.toHaveBeenCalled(); + }); + + it("finds the return among a second instance's arguments, whatever the scheme's case", () => { + const f = fixture(); + expect(f.sso.linkIn(['C:\\Resgrid Unit\\Resgrid Unit.exe', '--allow-file-access', 'ResgridUnit://auth/callback?code=x&state=y'])).toBe('ResgridUnit://auth/callback?code=x&state=y'); + expect(f.sso.linkIn(['Resgrid Unit.exe', 'resgridunit://calls/42'])).toBeNull(); + expect(f.sso.linkIn(undefined)).toBeNull(); + }); +}); + +describe('registerLegacySso', () => { + it('answers the renderer on three channels', async () => { + const handlers: Record unknown> = {}; + const ipcMain = { handle: jest.fn((channel: string, handler: (...args: unknown[]) => unknown) => (handlers[channel] = handler)) }; + const opened: string[] = []; + const sso = registerLegacySso(ipcMain, { + scheme: 'resgridunit', + openExternal: async (url: string) => { + opened.push(url); + }, + fetch: async () => json({}, 500), + }); + + expect(Object.keys(handlers).sort()).toEqual(['legacy-sso:cancel', 'legacy-sso:oidc', 'legacy-sso:saml']); + await expect(handlers['legacy-sso:oidc']({}, AUTHORITY, 'c')).resolves.toEqual({ ok: false, reason: 'failed' }); + + // Only a literal true from the page asks for reauthentication. + const oidc = jest.spyOn(sso, 'oidc'); + await handlers['legacy-sso:oidc']({}, AUTHORITY, 'c', 'yes'); + await handlers['legacy-sso:oidc']({}, AUTHORITY, 'c', true); + expect(oidc.mock.calls).toEqual([ + [AUTHORITY, 'c', false], + [AUTHORITY, 'c', true], + ]); + + const saml = handlers['legacy-sso:saml']({}, START) as Promise; + await tick(); + expect(opened).toEqual([START]); + expect(sso.waiting).toBe(true); + handlers['legacy-sso:cancel']({}); + await expect(saml).resolves.toEqual({ ok: false, reason: 'cancelled' }); + }); +}); diff --git a/electron/__tests__/main-links.test.ts b/electron/__tests__/main-links.test.ts new file mode 100644 index 00000000..3a0de901 --- /dev/null +++ b/electron/__tests__/main-links.test.ts @@ -0,0 +1,117 @@ +/** + * The desktop app's main process hands its scheme's links to the waiting legacy sign-in (electron/main.js): the scheme is + * registered, macOS's open-url and a second instance's argv (Windows, Linux) both reach it, and only one instance runs. + * Electron itself is a stand-in here; the main process's own handlers run. + * + * @jest-environment node + */ + +const mockAppEvents: Record void> = {}; +const mockIpcHandlers: Record unknown> = {}; +const mockApp = { + on: jest.fn((event: string, handler: (...args: unknown[]) => void) => { + mockAppEvents[event] = handler; + }), + setAsDefaultProtocolClient: jest.fn(), + requestSingleInstanceLock: jest.fn(() => true), + // Never ready: no window is made in this test. + whenReady: jest.fn(() => new Promise(() => undefined)), + quit: jest.fn(), + isPackaged: true, + name: 'Resgrid Unit', +}; +const mockShell = { openExternal: jest.fn(async () => undefined) }; +const mockWindow = { + on: jest.fn(), + once: jest.fn(), + loadURL: jest.fn(), + isMinimized: () => false, + restore: jest.fn(), + focus: jest.fn(), + show: jest.fn(), + webContents: { on: jest.fn(), send: jest.fn(), setWindowOpenHandler: jest.fn(), isLoading: () => false, openDevTools: jest.fn() }, +}; + +jest.mock( + 'electron', + () => ({ + app: mockApp, + BrowserWindow: Object.assign( + jest.fn(() => mockWindow), + { getAllWindows: () => [] } + ), + ipcMain: { + handle: (channel: string, handler: (...args: unknown[]) => unknown) => { + mockIpcHandlers[channel] = handler; + }, + on: jest.fn(), + }, + Notification: { isSupported: () => false }, + nativeTheme: { shouldUseDarkColors: false }, + Menu: { buildFromTemplate: jest.fn(), setApplicationMenu: jest.fn() }, + protocol: { registerSchemesAsPrivileged: jest.fn(), handle: jest.fn() }, + net: { fetch: jest.fn() }, + shell: mockShell, + }), + { virtual: true } +); +jest.mock('electron-squirrel-startup', () => false, { virtual: true }); + +const START = 'https://api.resgrid.com/api/v4/connect/saml-mobile-login?departmentToken=enc&RelayState=unit.3f2b8c1e-5d7a-4e9b-8a61-0c4d2e7f9b13'; +const RETURN = 'resgridunit://auth/callback?saml_response=saml-relay%3Aabc&department_token=enc&relay_state=unit.3f2b8c1e-5d7a-4e9b-8a61-0c4d2e7f9b13'; +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +describe('main process: the app scheme reaches the waiting legacy sign-in', () => { + beforeAll(() => { + jest.isolateModules(() => { + require('../main'); + }); + }); + + it("registers this app's scheme and holds the single-instance lock", () => { + expect(mockApp.setAsDefaultProtocolClient).toHaveBeenCalledWith('resgridunit'); + expect(mockApp.requestSingleInstanceLock).toHaveBeenCalled(); + expect(mockApp.quit).not.toHaveBeenCalled(); + expect(Object.keys(mockIpcHandlers)).toEqual(expect.arrayContaining(['legacy-sso:oidc', 'legacy-sso:saml', 'legacy-sso:cancel'])); + }); + + it("takes the return from macOS's open-url", async () => { + const signIn = mockIpcHandlers['legacy-sso:saml']({}, START) as Promise; + await tick(); + expect(mockShell.openExternal).toHaveBeenLastCalledWith(START); + + const event = { preventDefault: jest.fn() }; + mockAppEvents['open-url'](event, RETURN); + expect(event.preventDefault).toHaveBeenCalled(); + await expect(signIn).resolves.toEqual({ ok: true, url: RETURN }); + }); + + it("takes the return from a second instance's arguments on Windows and Linux", async () => { + const signIn = mockIpcHandlers['legacy-sso:saml']({}, START) as Promise; + await tick(); + + mockAppEvents['second-instance']({}, ['C:\\Program Files\\Resgrid Unit\\Resgrid Unit.exe', '--flag', RETURN]); + await expect(signIn).resolves.toEqual({ ok: true, url: RETURN }); + }); + + it('a second launch without a link ends nothing', async () => { + const signIn = mockIpcHandlers['legacy-sso:saml']({}, START) as Promise; + await tick(); + mockAppEvents['second-instance']({}, ['Resgrid Unit.exe']); + mockAppEvents['open-url']({ preventDefault: jest.fn() }, 'resgridunit://calls/42'); + + mockIpcHandlers['legacy-sso:cancel']({}); + await expect(signIn).resolves.toEqual({ ok: false, reason: 'cancelled' }); + }); +}); + +describe('main process: a second instance', () => { + it('quits once it has handed its link to the first', () => { + mockApp.requestSingleInstanceLock.mockReturnValueOnce(false); + mockApp.quit.mockClear(); + jest.isolateModules(() => { + require('../main'); + }); + expect(mockApp.quit).toHaveBeenCalled(); + }); +}); diff --git a/electron/__tests__/sso-loopback.test.ts b/electron/__tests__/sso-loopback.test.ts new file mode 100644 index 00000000..264d6085 --- /dev/null +++ b/electron/__tests__/sso-loopback.test.ts @@ -0,0 +1,124 @@ +/** + * The desktop loopback for brokered SSO (electron/sso-loopback.js): one listener per round trip on 127.0.0.1, exactly one + * return on /sso-return, https-only provider pages, and cancel/timeout closing the listener. + * + * @jest-environment node + */ +import http from 'http'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { createSsoLoopback, registerSsoLoopback, RETURN_PATH } = require('../sso-loopback'); + +const get = (url: string): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> => + new Promise((resolve, reject) => { + http + .get(url, (response) => { + let body = ''; + response.on('data', (chunk) => (body += chunk)); + response.on('end', () => resolve({ status: response.statusCode ?? 0, body, headers: response.headers })); + }) + .on('error', reject); + }); + +describe('createSsoLoopback', () => { + it('listens on 127.0.0.1 and hands back the one return the provider sends', async () => { + const openExternal = jest.fn(async () => undefined); + const focus = jest.fn(); + const loopback = createSsoLoopback({ openExternal, focus }); + + const listener = await loopback.listen(); + expect(listener.returnTarget).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/sso-return$/); + expect(RETURN_PATH).toBe('/sso-return'); + + const returned = loopback.open(listener.id, 'https://login.resgrid.com/sso/authorize?x=1'); + await new Promise((resolve) => setImmediate(resolve)); + expect(openExternal).toHaveBeenCalledWith('https://login.resgrid.com/sso/authorize?x=1'); + + const page = await get(`${listener.returnTarget}?sso_code=abc&state=xyz`); + expect(page.status).toBe(200); + expect(page.headers['cache-control']).toBe('no-store'); + expect(page.body).toContain('Sign-in finished'); + + await expect(returned).resolves.toBe(`${listener.returnTarget}?sso_code=abc&state=xyz`); + expect(focus).toHaveBeenCalledTimes(1); + expect(loopback.openTrips).toBe(0); + + // One return only: the listener is gone. + expect(page.headers.connection).toBe('close'); + await expect(get(`${listener.returnTarget}?sso_code=again`)).rejects.toThrow(); + }); + + it('answers anything but GET /sso-return with 404 and keeps waiting', async () => { + const loopback = createSsoLoopback({ openExternal: async () => undefined }); + const listener = await loopback.listen(); + const port = new URL(listener.returnTarget).port; + + const other = await get(`http://127.0.0.1:${port}/favicon.ico`); + expect(other.status).toBe(404); + expect(loopback.openTrips).toBe(1); + + loopback.cancel(listener.id); + expect(loopback.openTrips).toBe(0); + }); + + it('refuses a provider page that is not https, and closes the listener', async () => { + const openExternal = jest.fn(async () => undefined); + const loopback = createSsoLoopback({ openExternal }); + const listener = await loopback.listen(); + + await expect(loopback.open(listener.id, 'http://evil.example/authorize')).resolves.toBeNull(); + await expect(loopback.open(listener.id, 'not a url')).resolves.toBeNull(); + expect(openExternal).not.toHaveBeenCalled(); + expect(loopback.openTrips).toBe(0); + }); + + it('closes the listener and resolves null when the browser cannot be opened', async () => { + const loopback = createSsoLoopback({ openExternal: jest.fn(async () => Promise.reject(new Error('no browser'))) }); + const listener = await loopback.listen(); + + await expect(loopback.open(listener.id, 'https://login.resgrid.com/sso/authorize')).resolves.toBeNull(); + expect(loopback.openTrips).toBe(0); + await expect(get(`${listener.returnTarget}?sso_code=late`)).rejects.toThrow(); + }); + + it('resolves null on cancel and on timeout', async () => { + const loopback = createSsoLoopback({ openExternal: async () => undefined, waitMs: 50 }); + + const first = await loopback.listen(); + const cancelled = loopback.open(first.id, 'https://login.resgrid.com/a'); + await new Promise((resolve) => setImmediate(resolve)); + loopback.cancel(first.id); + await expect(cancelled).resolves.toBeNull(); + + const second = await loopback.listen(); + await expect(loopback.open(second.id, 'https://login.resgrid.com/b')).resolves.toBeNull(); + expect(loopback.openTrips).toBe(0); + + await expect(loopback.open('unknown', 'https://login.resgrid.com/c')).resolves.toBeNull(); + }); + + it('gives every round trip its own listener', async () => { + const loopback = createSsoLoopback({ openExternal: async () => undefined }); + const a = await loopback.listen(); + const b = await loopback.listen(); + expect(a.id).not.toBe(b.id); + expect(a.returnTarget).not.toBe(b.returnTarget); + loopback.cancel(a.id); + loopback.cancel(b.id); + }); +}); + +describe('registerSsoLoopback', () => { + it('exposes listen, open and cancel over IPC', async () => { + const handlers = new Map unknown>(); + const ipcMain = { handle: (channel: string, handler: (...args: unknown[]) => unknown) => handlers.set(channel, handler) }; + registerSsoLoopback(ipcMain, { openExternal: async () => undefined }); + + expect([...handlers.keys()].sort()).toEqual(['sso:cancel', 'sso:listen', 'sso:open']); + const listener = (await handlers.get('sso:listen')!()) as { id: string; returnTarget: string }; + const opened = handlers.get('sso:open')!({}, listener.id, 'https://login.resgrid.com/x') as Promise; + await new Promise((resolve) => setImmediate(resolve)); + handlers.get('sso:cancel')!({}, listener.id); + await expect(opened).resolves.toBeNull(); + }); +}); diff --git a/electron/legacy-sso.js b/electron/legacy-sso.js new file mode 100644 index 00000000..fa5bcbbe --- /dev/null +++ b/electron/legacy-sso.js @@ -0,0 +1,203 @@ +/* eslint-disable no-undef */ +const crypto = require('crypto'); + +const CALLBACK_HOST = 'auth'; +const CALLBACK_PATH = '/callback'; +const WAIT_MS = 10 * 60 * 1000; +const SCOPES = 'openid email profile offline_access'; + +const failed = { ok: false, reason: 'failed' }; +const cancelled = { ok: false, reason: 'cancelled' }; + +const base64Url = (bytes) => Buffer.from(bytes).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); + +/** An https URL, or http on this computer's loopback (a development server); null for anything else. */ +const safeUrl = (value) => { + let url; + try { + url = new URL(value); + } catch { + return null; + } + const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]'; + return url.protocol === 'https:' || (url.protocol === 'http:' && loopback) ? url : null; +}; + +/** + * Legacy (unbrokered) SSO in the desktop app. Without the broker, the department's OIDC provider and the server's SAML + * relay return the member to this app's own scheme, `://auth/callback`: the redirect URI the department already + * registers for the mobile app. The OS hands that link to this process (open-url on macOS, a second instance's argv on + * Windows and Linux). The page, served from app://, can receive neither, and a provider refuses to redeem a desktop + * app's OIDC code cross-origin. So this process runs the OIDC code flow as a native client does: PKCE and state, the + * provider in the member's own browser, the code redeemed here, and only the id_token handed to the page. For SAML it + * opens the server's start page and hands the relay's link back to the page, which checks its own RelayState. + * + * On a shared installation the page asks for a fresh sign-in (reauthenticate): the provider is told to authenticate the + * member again (prompt=login, max_age=0), because the member's browser may still hold the last operator's provider + * session, and the server refuses a sign-in that is not fresh. + * + * One sign-in waits at a time; a new one replaces it, and cancel or ten minutes end it. A link that matches nothing + * waiting (no sign-in, another state, another RelayState) is ignored, so a forged link cannot end the member's sign-in. + */ +function createLegacySso({ scheme, openExternal, fetch, focus, waitMs = WAIT_MS }) { + const protocol = `${scheme.toLowerCase()}:`; + // The registered form of the redirect URI: lowercase, as the department enters it with its provider. + const redirectUri = `${scheme.toLowerCase()}://${CALLBACK_HOST}${CALLBACK_PATH}`; + let waiting = null; + + const settle = (outcome) => { + if (!waiting) { + return; + } + const trip = waiting; + waiting = null; + clearTimeout(trip.timer); + trip.resolve(outcome); + }; + + const wait = (accept) => { + settle(cancelled); + return new Promise((resolve) => { + waiting = { accept, resolve, timer: setTimeout(() => settle(cancelled), waitMs) }; + }); + }; + + const open = async (url) => { + try { + await openExternal(url); + } catch { + settle(failed); + } + }; + + /** This app's sign-in return, or null for any other link. */ + const callbackParams = (link) => { + let url; + try { + url = new URL(link); + } catch { + return null; + } + return url.protocol === protocol && url.hostname === CALLBACK_HOST && url.pathname === CALLBACK_PATH ? url.searchParams : null; + }; + + /** + * A link the OS handed this app. True when it is this app's sign-in return (taken, or ignored when nothing waits for + * it), so the caller does not pass it on; false for any other link. + */ + const handleLink = (link) => { + const params = callbackParams(link); + if (!params) { + return false; + } + if (waiting && waiting.accept(params)) { + if (focus) { + focus(); + } + settle({ ok: true, params, link }); + } + return true; + }; + + const oidc = async (authority, clientId, reauthenticate = false) => { + const issuer = safeUrl(authority); + if (!issuer || typeof clientId !== 'string' || !clientId) { + return failed; + } + + let metadata; + try { + const response = await fetch(`${issuer.toString().replace(/\/+$/, '')}/.well-known/openid-configuration`, { headers: { Accept: 'application/json' } }); + metadata = response.ok ? await response.json() : null; + } catch { + metadata = null; + } + const authorizeUrl = safeUrl(metadata?.authorization_endpoint); + const tokenUrl = safeUrl(metadata?.token_endpoint); + if (!authorizeUrl || !tokenUrl) { + return failed; + } + + const verifier = base64Url(crypto.randomBytes(32)); + const state = base64Url(crypto.randomBytes(24)); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', clientId); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('scope', SCOPES); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('code_challenge', base64Url(crypto.createHash('sha256').update(verifier).digest())); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + if (reauthenticate) { + authorizeUrl.searchParams.set('prompt', 'login'); + authorizeUrl.searchParams.set('max_age', '0'); + } + + const returned = wait((params) => params.get('state') === state); + await open(authorizeUrl.toString()); + const outcome = await returned; + if (!outcome.ok) { + return outcome; + } + + const error = outcome.params.get('error'); + if (error) { + return { ok: false, reason: error === 'access_denied' ? 'denied' : 'failed', code: error }; + } + const code = outcome.params.get('code'); + if (!code) { + return failed; + } + + try { + const response = await fetch(tokenUrl.toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, code_verifier: verifier }).toString(), + }); + const tokens = response.ok ? await response.json() : null; + return typeof tokens?.id_token === 'string' && tokens.id_token ? { ok: true, idToken: tokens.id_token } : failed; + } catch { + return failed; + } + }; + + const saml = async (signInUrl) => { + const page = safeUrl(signInUrl); + const relayState = page?.searchParams.get('RelayState'); + if (!page || !relayState) { + return failed; + } + + const returned = wait((params) => !!params.get('saml_response') && params.get('relay_state') === relayState); + await open(page.toString()); + const outcome = await returned; + return outcome.ok ? { ok: true, url: outcome.link } : outcome; + }; + + const cancel = () => settle(cancelled); + + /** The sign-in return among a second instance's arguments, if any. */ + const linkIn = (argv) => (Array.isArray(argv) ? (argv.find((arg) => typeof arg === 'string' && callbackParams(arg)) ?? null) : null); + + return { + oidc, + saml, + cancel, + handleLink, + linkIn, + redirectUri, + get waiting() { + return waiting !== null; + }, + }; +} + +function registerLegacySso(ipcMain, options) { + const legacySso = createLegacySso(options); + ipcMain.handle('legacy-sso:oidc', (_event, authority, clientId, reauthenticate) => legacySso.oidc(authority, clientId, reauthenticate === true)); + ipcMain.handle('legacy-sso:saml', (_event, signInUrl) => legacySso.saml(signInUrl)); + ipcMain.handle('legacy-sso:cancel', () => legacySso.cancel()); + return legacySso; +} + +module.exports = { createLegacySso, registerLegacySso }; diff --git a/electron/main.js b/electron/main.js index 88b12d6c..4b9d23e4 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,5 +1,7 @@ /* eslint-disable no-undef */ -const { app, BrowserWindow, ipcMain, Notification, nativeTheme, Menu, protocol, net } = require('electron'); +const { app, BrowserWindow, ipcMain, Notification, nativeTheme, Menu, protocol, net, shell } = require('electron'); +const { registerSsoLoopback } = require('./sso-loopback'); +const { registerLegacySso } = require('./legacy-sso'); const path = require('path'); const fs = require('fs'); const { pathToFileURL } = require('url'); @@ -28,6 +30,54 @@ if (require('electron-squirrel-startup')) { let mainWindow = null; const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; +// This app's own scheme, as the mobile app has it: legacy SSO returns to resgridunit://auth/callback (legacy-sso.js). +const APP_SCHEME = 'resgridunit'; +if (process.defaultApp && process.argv.length >= 2) { + // Unpackaged (electron .): the OS must start Electron with this app's entry script. + app.setAsDefaultProtocolClient(APP_SCHEME, process.execPath, [path.resolve(process.argv[1])]); +} else { + app.setAsDefaultProtocolClient(APP_SCHEME); +} + +function focusMainWindow() { + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.focus(); + } +} + +// Legacy SSO: the provider and the SAML relay return through the OS to this app's scheme. +const legacySso = registerLegacySso(ipcMain, { + scheme: APP_SCHEME, + openExternal: (url) => shell.openExternal(url), + // The main process's network stack: the system's proxy settings, and no Origin header on the provider's token request. + fetch: (url, init) => net.fetch(url, init), + focus: focusMainWindow, +}); + +// One instance: on Windows and Linux the OS starts a second instance with the scheme's link, which hands it to this one +// and quits. A second launch from the dock or start menu shows this window. +const gotSingleInstanceLock = app.requestSingleInstanceLock(); +if (!gotSingleInstanceLock) { + app.quit(); +} else { + app.on('second-instance', (_event, argv) => { + focusMainWindow(); + const link = legacySso.linkIn(argv); + if (link) { + legacySso.handleLink(link); + } + }); +} + +// macOS hands this app its scheme's links here. +app.on('open-url', (event, url) => { + event.preventDefault(); + legacySso.handleLink(url); +}); + function createWindow() { // Create the browser window. mainWindow = new BrowserWindow({ @@ -174,6 +224,12 @@ ipcMain.handle('show-notification', async (event, { title, body, data }) => { return true; }); +// Brokered SSO returns to a one-time loopback listener; the provider opens in the member's own browser. +registerSsoLoopback(ipcMain, { + openExternal: (url) => shell.openExternal(url), + focus: focusMainWindow, +}); + // Handle getting platform info ipcMain.handle('get-platform', () => { return process.platform; @@ -181,6 +237,11 @@ ipcMain.handle('get-platform', () => { // Handle app ready app.whenReady().then(() => { + // A second instance only hands its link to the first, then quits. + if (!gotSingleInstanceLock) { + return; + } + // Register custom protocol handler for serving the Expo web export // This resolves absolute paths like /_expo/static/js/... from the dist directory const distPath = path.join(__dirname, '..', 'dist'); @@ -233,10 +294,3 @@ app.on('window-all-closed', () => { app.quit(); } }); - -// Handle deep links (for future use) -app.on('open-url', (event, url) => { - event.preventDefault(); - // Handle the URL - console.log('Deep link received:', url); -}); diff --git a/electron/preload.js b/electron/preload.js index f6a5dfaf..ac2490ee 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -21,6 +21,17 @@ contextBridge.exposeInMainWorld('electronAPI', { // Platform queries getPlatform: () => ipcRenderer.invoke('get-platform'), + // Brokered SSO: a one-time loopback listener receives the broker's return (see sso-loopback.js) + ssoListen: () => ipcRenderer.invoke('sso:listen'), + ssoOpen: (id, authorizeUrl) => ipcRenderer.invoke('sso:open', id, authorizeUrl), + ssoCancel: (id) => ipcRenderer.invoke('sso:cancel', id), + + // Legacy SSO: the main process runs the provider's sign-in in the member's browser and takes the return on this app's + // scheme (see legacy-sso.js). OIDC answers with the id_token; SAML with the relay's link, for the page to check. + legacySsoOidc: (authority, clientId, reauthenticate) => ipcRenderer.invoke('legacy-sso:oidc', authority, clientId, reauthenticate), + legacySsoSaml: (signInUrl) => ipcRenderer.invoke('legacy-sso:saml', signInUrl), + legacySsoCancel: () => ipcRenderer.invoke('legacy-sso:cancel'), + // Window controls (for custom title bar if needed) minimizeWindow: () => ipcRenderer.send('minimize-window'), maximizeWindow: () => ipcRenderer.send('maximize-window'), diff --git a/electron/sso-loopback.js b/electron/sso-loopback.js new file mode 100644 index 00000000..bcef5986 --- /dev/null +++ b/electron/sso-loopback.js @@ -0,0 +1,111 @@ +/* eslint-disable no-undef */ +const http = require('http'); +const crypto = require('crypto'); + +const RETURN_PATH = '/sso-return'; +const WAIT_MS = 10 * 60 * 1000; +const PAGE = + 'Resgrid' + + '

Sign-in finished. You can close this window and return to Resgrid.

'; + +/** + * Brokered SSO in the desktop app (passkey workbook section 7.3). The packaged app's page cannot receive a browser + * redirect, so the broker returns to a one-time listener on 127.0.0.1 (RFC 8252 section 7.3) instead of a custom scheme, + * which also keeps Unit, IC and Dispatch on one laptop from colliding. Each round trip gets its own listener on a random + * port. It answers exactly one GET on /sso-return with a page the member can close, hands that address (the one-time + * code and state) back to the page that asked, and closes; a cancel or ten minutes close it too. PKCE and the state bind + * the code to this app's round trip, so nothing else is trusted here. + */ +function createSsoLoopback({ openExternal, focus, waitMs = WAIT_MS }) { + const trips = new Map(); + + const close = (id, url) => { + const trip = trips.get(id); + if (!trip) { + return; + } + trips.delete(id); + clearTimeout(trip.timer); + trip.server.close(); + // No kept-alive connection outlives the one return. + if (typeof trip.server.closeIdleConnections === 'function') { + trip.server.closeIdleConnections(); + } + trip.settle(url); + }; + + const listen = () => + new Promise((resolve, reject) => { + const id = crypto.randomUUID(); + const server = http.createServer((request, response) => { + const url = new URL(request.url, 'http://127.0.0.1'); + if (request.method !== 'GET' || url.pathname !== RETURN_PATH || !trips.has(id)) { + response.writeHead(404).end(); + return; + } + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', Connection: 'close' }); + response.end(PAGE); + if (focus) { + focus(); + } + close(id, `http://127.0.0.1:${server.address().port}${request.url}`); + }); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + let settle; + const done = new Promise((r) => { + settle = r; + }); + trips.set(id, { server, settle, done, timer: setTimeout(() => close(id, null), waitMs) }); + resolve({ id, returnTarget: `http://127.0.0.1:${server.address().port}${RETURN_PATH}` }); + }); + }); + + // Opens the provider in the system browser and waits for this listener's one return; null when cancelled or timed out. + const open = async (id, authorizeUrl) => { + const trip = trips.get(id); + if (!trip) { + return null; + } + let parsed; + try { + parsed = new URL(authorizeUrl); + } catch { + close(id, null); + return null; + } + if (parsed.protocol !== 'https:') { + close(id, null); + return null; + } + try { + await openExternal(parsed.toString()); + } catch { + // No browser opened, so no return can come: release the listener now instead of after ten minutes. + close(id, null); + return null; + } + return trip.done; + }; + + const cancel = (id) => close(id, null); + + return { + listen, + open, + cancel, + get openTrips() { + return trips.size; + }, + }; +} + +function registerSsoLoopback(ipcMain, options) { + const loopback = createSsoLoopback(options); + ipcMain.handle('sso:listen', () => loopback.listen()); + ipcMain.handle('sso:open', (_event, id, authorizeUrl) => loopback.open(id, authorizeUrl)); + ipcMain.handle('sso:cancel', (_event, id) => loopback.cancel(id)); + return loopback; +} + +module.exports = { createSsoLoopback, registerSsoLoopback, RETURN_PATH }; diff --git a/package.json b/package.json index b96b9f66..bba5a118 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "react-native-keyboard-controller": "1.21.9", "react-native-logs": "5.3.0", "react-native-mmkv": "3.1.0", + "react-native-passkey": "3.6.2", "react-native-reanimated": "4.5.1", "react-native-restart": "0.0.27", "react-native-safe-area-context": "~5.7.0", diff --git a/src/api/common/__tests__/client-shared-session.test.ts b/src/api/common/__tests__/client-shared-session.test.ts new file mode 100644 index 00000000..9909a716 --- /dev/null +++ b/src/api/common/__tests__/client-shared-session.test.ts @@ -0,0 +1,93 @@ +/** + * The API client's answer to a shared session (passkey plan section 10.5): a locked 401 shows the lock screen and never + * refreshes or signs out; an expired shift signs out with a reason; an application refusal (a 401 with a problem type) + * is passed straight back without a refresh or a replay. + */ +const mockResponseInterceptorUse = jest.fn(); +const mockGetAuthState = jest.fn(); +const mockMarkLocked = jest.fn(); +const mockAxiosInstance = Object.assign(jest.fn(), { + defaults: { headers: { common: {} } }, + interceptors: { request: { use: jest.fn() }, response: { use: mockResponseInterceptorUse } }, +}); +const mockCreateConfigs: Record[] = []; + +jest.mock('axios', () => ({ + __esModule: true, + default: { + create: jest.fn((config: Record) => { + mockCreateConfigs.push(config); + return mockAxiosInstance; + }), + }, +})); +jest.mock('@/lib/logging', () => ({ logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() } })); +jest.mock('@/lib/storage/app', () => ({ getBaseApiUrl: jest.fn(() => 'https://example.test') })); +jest.mock('@/stores/auth/store', () => ({ __esModule: true, default: { getState: mockGetAuthState } })); +jest.mock('@/stores/shared-session/store', () => ({ markSharedSessionLocked: (...args: unknown[]) => mockMarkLocked(...args) })); + +let rejectResponse: (error: unknown) => Promise; + +const failure = (status: number, data: unknown) => ({ config: { headers: { get: jest.fn() } }, response: { status, data } }); + +describe('API client and shared sessions', () => { + const refreshAccessToken = jest.fn(); + const logout = jest.fn(); + + beforeAll(() => { + jest.isolateModules(() => { + require('@/api/common/client'); + }); + rejectResponse = mockResponseInterceptorUse.mock.calls[0]?.[1] as (error: unknown) => Promise; + }); + + beforeEach(() => { + jest.clearAllMocks(); + refreshAccessToken.mockResolvedValue(true); + logout.mockResolvedValue(undefined); + mockGetAuthState.mockReturnValue({ refreshAccessToken, logout, refreshToken: 'refresh-token', accessToken: 'access-token' }); + }); + + it('names this app on every request', () => { + expect((mockCreateConfigs[0]?.headers as Record)['X-Resgrid-Client']).toBe('unit'); + }); + + it('shows the lock screen for a locked session: no refresh, no sign-out', async () => { + const error = failure(401, { error: 'shared_session_locked', lock_version: 12 }); + + await expect(rejectResponse(error)).rejects.toBe(error); + + expect(mockMarkLocked).toHaveBeenCalledWith(12); + expect(refreshAccessToken).not.toHaveBeenCalled(); + expect(logout).not.toHaveBeenCalled(); + }); + + it('signs out with the shift-ended reason when the shift ran out', async () => { + const error = failure(401, { error: 'shared_session_expired' }); + + await expect(rejectResponse(error)).rejects.toBe(error); + + expect(logout).toHaveBeenCalledWith('shift_ended'); + expect(refreshAccessToken).not.toHaveBeenCalled(); + expect(mockMarkLocked).not.toHaveBeenCalled(); + }); + + it('passes an application refusal straight back: no refresh, no replay', async () => { + const error = failure(401, { type: 'invalid_totp', status: 401 }); + + await expect(rejectResponse(error)).rejects.toBe(error); + + expect(refreshAccessToken).not.toHaveBeenCalled(); + expect(mockAxiosInstance).not.toHaveBeenCalled(); + }); + + it('still refreshes and replays for an expired access token (a 401 without a body)', async () => { + mockAxiosInstance.mockResolvedValueOnce({ data: 'ok' }); + const error = { config: { headers: { get: jest.fn() } }, response: { status: 401, data: '' } }; + + await expect(rejectResponse(error)).resolves.toEqual({ data: 'ok' }); + + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(mockMarkLocked).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx index b3144806..869096bd 100644 --- a/src/api/common/client.tsx +++ b/src/api/common/client.tsx @@ -1,9 +1,12 @@ import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'; +import { sharedSession401 } from '@/lib/auth/token-refresh'; 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'; +import { markSharedSessionLocked } from '@/stores/shared-session/store'; // Create axios instance with default config const axiosInstance: AxiosInstance = axios.create({ @@ -13,6 +16,8 @@ const axiosInstance: AxiosInstance = axios.create({ timeout: 30000, 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, }, }); @@ -76,6 +81,25 @@ axiosInstance.interceptors.response.use( if (!originalRequest) { return Promise.reject(error); } + // A shared vehicle session (passkey plan section 10.5): a locked session means "unlock", never "sign out" and never a + // refresh (a refresh is refused while locked too). A shift that ran out ends the session for good. + const sharedSession = sharedSession401(error); + if (sharedSession?.kind === 'locked') { + markSharedSessionLocked(sharedSession.lockVersion); + return Promise.reject(error); + } + if (sharedSession?.kind === 'expired') { + void useAuthStore.getState().logout('shift_ended'); + 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, and a refresh refused while locked would replace the refusal the screen needs to show. + 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) { // Mark as retried immediately — also covers requests queued while a 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/__tests__/shared-session.test.ts b/src/api/mfa/__tests__/shared-session.test.ts new file mode 100644 index 00000000..6400464a --- /dev/null +++ b/src/api/mfa/__tests__/shared-session.test.ts @@ -0,0 +1,53 @@ +const mockGet = jest.fn(); +const mockPost = jest.fn(); +const mockDelete = jest.fn(); + +jest.mock('../../common/client', () => ({ api: { get: (...a: unknown[]) => mockGet(...a), post: (...a: unknown[]) => mockPost(...a), delete: (...a: unknown[]) => mockDelete(...a) } })); + +import { beginUnlockSso, cancelUnlockApproval, completeUnlock, endShift, getCurrentSession, getUnlockApprovalStatus, getUnlockOptions, lockSession, requestUnlockApproval } from '../shared-session'; + +const ok = (Data: unknown) => Promise.resolve({ data: { Data } }); + +describe('shared session API (passkey workbook section 8)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGet.mockImplementation(() => ok({ Locked: false })); + mockPost.mockImplementation(() => ok({ Locked: true, LockVersion: 3 })); + mockDelete.mockImplementation(() => ok({ State: 'canceled' })); + }); + + it('reports operator activity only when asked; polling never does', async () => { + await getCurrentSession(); + expect(mockGet).toHaveBeenLastCalledWith('/sessions/current', {}); + + await getCurrentSession(true); + expect(mockGet).toHaveBeenLastCalledWith('/sessions/current', { headers: { 'X-Resgrid-Operator-Activity': '1' } }); + }); + + it('sends a body on every POST, PascalCase, with the lock version the unlock answers', async () => { + await expect(lockSession()).resolves.toEqual({ Locked: true, LockVersion: 3 }); + expect(mockPost).toHaveBeenLastCalledWith('/sessions/lock', {}); + + await endShift(true); + expect(mockPost).toHaveBeenLastCalledWith('/sessions/end-shift', { SwitchOperator: true }); + + await getUnlockOptions(3); + expect(mockPost).toHaveBeenLastCalledWith('/sessions/unlock-options', { LockVersion: 3 }); + + await requestUnlockApproval(3); + expect(mockPost).toHaveBeenLastCalledWith('/sessions/unlock-approval', { LockVersion: 3 }); + + await beginUnlockSso(3, { returnTarget: 'resgridunit://sso-return', state: 's', codeChallenge: 'c', platform: 'ios' }); + expect(mockPost).toHaveBeenLastCalledWith('/sessions/unlock-sso', { LockVersion: 3, Platform: 'ios', ReturnTarget: 'resgridunit://sso-return', State: 's', CodeChallenge: 'c', CodeChallengeMethod: 'S256' }); + + await completeUnlock(3, { Method: 'totp', Code: '123456' }); + expect(mockPost).toHaveBeenLastCalledWith('/sessions/complete-unlock', { LockVersion: 3, Method: 'totp', Code: '123456' }); + }); + + it("reads and cancels this session's unlock approval by its id", async () => { + await getUnlockApprovalStatus('a/b'); + expect(mockGet).toHaveBeenLastCalledWith('/sessions/unlock-approval/a%2Fb'); + await cancelUnlockApproval('a/b'); + expect(mockDelete).toHaveBeenLastCalledWith('/sessions/unlock-approval/a%2Fb'); + }); +}); diff --git a/src/api/mfa/account-security.ts b/src/api/mfa/account-security.ts new file mode 100644 index 00000000..ffe70bdf --- /dev/null +++ b/src/api/mfa/account-security.ts @@ -0,0 +1,147 @@ +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, the Responder installations that approve sign-ins, 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; + +// ---- 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/api/mfa/shared-session.ts b/src/api/mfa/shared-session.ts new file mode 100644 index 00000000..a93bf42c --- /dev/null +++ b/src/api/mfa/shared-session.ts @@ -0,0 +1,71 @@ +import { OPERATOR_ACTIVITY_HEADER } from '@/lib/mfa/client-app'; +import type { ApprovalRequestData, ApprovalStatusData, PasskeyCeremonyData, SsoBeginData } from '@/lib/mfa/types'; +import type { CurrentSessionData } from '@/stores/shared-session/store'; + +import { api } from '../common/client'; + +// --------------------------------------------------------------------------- +// The shared vehicle session calls (passkey plan section 10.5, workbook section 8): bearer, and the only API paths a +// locked session may still use. Unlocking resumes the same session, so the tokens are kept throughout. Bodies are +// PascalCase; results carry their payload in `Data`. +// --------------------------------------------------------------------------- + +const data = async (request: Promise<{ data: { Data: T } }>): Promise => (await request).data.Data; + +/** + * The server's view of this session. `activity` reports real operator input, which moves the idle deadline; polling + * never sends it. + */ +export const getCurrentSession = (activity = false) => data(api.get('/sessions/current', activity ? { headers: { [OPERATOR_ACTIVITY_HEADER]: '1' } } : {})); + +export const lockSession = () => data<{ Locked: boolean; LockVersion: number }>(api.post('/sessions/lock', {})); + +/** Ends this operator's shift on the installation; the caller then drops tokens, caches and sockets. A body is required. */ +export const endShift = (switchOperator: boolean) => data<{ Ended: boolean }>(api.post('/sessions/end-shift', { SwitchOperator: switchOperator })); + +export interface UnlockOptionsData { + Operator: string | null; + LockVersion: number; + /** Empty means no quick unlock: the shift has to end. */ + Methods: string[]; + Preferred: string | null; + /** Never started on its own on a shared installation: the operator chooses it. */ + Passkey: PasskeyCeremonyData | null; +} + +export const getUnlockOptions = (lockVersion: number) => data(api.post('/sessions/unlock-options', { LockVersion: lockVersion })); + +export const requestUnlockApproval = (lockVersion: number) => data(api.post('/sessions/unlock-approval', { LockVersion: lockVersion })); + +export const getUnlockApprovalStatus = (approvalRequestId: string) => data(api.get(`/sessions/unlock-approval/${encodeURIComponent(approvalRequestId)}`)); + +export const cancelUnlockApproval = (approvalRequestId: string) => data(api.delete(`/sessions/unlock-approval/${encodeURIComponent(approvalRequestId)}`)); + +export interface UnlockSsoSecrets { + returnTarget: string; + state: string; + codeChallenge: string; + platform: string; +} + +/** Begins the provider's own sign-in for this lock (the broker asks who is signing in, every time). */ +export const beginUnlockSso = (lockVersion: number, secrets: UnlockSsoSecrets) => + data( + api.post('/sessions/unlock-sso', { + LockVersion: lockVersion, + Platform: secrets.platform, + ReturnTarget: secrets.returnTarget, + State: secrets.state, + CodeChallenge: secrets.codeChallenge, + CodeChallengeMethod: 'S256', + }) + ); + +export type CompleteUnlockInput = + | { Method: 'totp'; Code: string } + | { Method: 'passkey'; RequestId: string; Credential: Record } + | { Method: 'passkey_approval'; ApprovalRequestId: string } + | { Method: 'federated'; SsoTransactionId: string; SsoCode: string; CodeVerifier: string }; + +/** Unlocks the same session; the result names the operator. */ +export const completeUnlock = (lockVersion: number, input: CompleteUnlockInput) => data(api.post('/sessions/complete-unlock', { LockVersion: lockVersion, ...input })); 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..e076a2f2 --- /dev/null +++ b/src/app/(app)/__tests__/account-security.test.tsx @@ -0,0 +1,94 @@ +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: 'unit', + Totp: { Enrolled: true, EnrolledOn: '2026-09-01T00:00:00Z', LastUsedOn: null, RecoveryCodesRemaining: 8, RecoveryCodeWarning: false, SetUpOnSharedInstallation: false, CanTurnOff: true }, + PasskeyGroups: [ + { Client: 'unit', 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 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)/_layout.tsx b/src/app/(app)/_layout.tsx index a7c2cb19..e4ed7dac 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -10,8 +10,11 @@ import { ActivityIndicator, type ColorValue, Platform, StyleSheet, useWindowDime import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 { SharedSessionBar } from '@/components/shared-session/shared-session-bar'; +import { SharedSessionLockScreen } from '@/components/shared-session/shared-session-lock-screen'; import Sidebar from '@/components/sidebar/sidebar'; import { FocusAwareStatusBar, View } from '@/components/ui'; import { Button, ButtonText } from '@/components/ui/button'; @@ -22,6 +25,7 @@ import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; +import { useSharedSessionLifecycle } from '@/hooks/use-shared-session-lifecycle'; import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle'; import { getAppTabBarHeight } from '@/lib/app-shell-layout'; import { useAuthStore } from '@/lib/auth'; @@ -57,6 +61,8 @@ export default function TabLayout() { const isLandscape = width > height; const { isActive, appState } = useAppLifecycle(); const { trackEvent } = useAnalytics(); + // A shared vehicle session: lock on background and restart, follow the idle lock and shift end (plan section 10.5). + const sharedSession = useSharedSessionLifecycle(status === 'signedIn'); // Identify user in Countly when signed in useEffect(() => { @@ -285,7 +291,8 @@ export default function TabLayout() { // Handle SignalR lifecycle management useSignalRLifecycle({ - isSignedIn: status === 'signedIn', + // A locked shared session keeps its tokens but must not reconnect the hubs on resume. + isSignedIn: status === 'signedIn' && !sharedSession.locked, hasInitialized: hasInitialized.current, }); @@ -583,13 +590,17 @@ export default function TabLayout() { } const content = ( - + {/* The app's single Advanced Data Protection prompt. Mounted here so any screen can trigger it through the store without carrying a modal of its own, and so two screens can never stack two prompts over each other. */} + + {/* Over everything while a shared vehicle session is locked; the same session resumes on unlock. */} + + {/* Loading overlay during initialization — shown on top of Tabs so the navigator stays mounted */} {!isInitComplete ? ( diff --git a/src/app/(app)/account-security.tsx b/src/app/(app)/account-security.tsx new file mode 100644 index 00000000..7a7ea621 --- /dev/null +++ b/src/app/(app)/account-security.tsx @@ -0,0 +1,303 @@ +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' }; + +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 < 3; 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; + if (!needed || !(await prove(needed))) { + setErrorCode(needed ? null : code); + 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)/operations/[id].tsx b/src/app/(app)/operations/[id].tsx index 2cc98d9c..aa4f586a 100644 --- a/src/app/(app)/operations/[id].tsx +++ b/src/app/(app)/operations/[id].tsx @@ -4,6 +4,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; +import { DateTimeField } from '@/components/common/date-time-field'; import { ExpensesPanel } from '@/components/operations/expenses-panel'; import { MarsPanel } from '@/components/operations/mars-panel'; import { AwaitingApproval, DayReports, ScopePicker, useScopeLabel } from '@/components/operations/scope-picker'; @@ -103,8 +104,7 @@ export default function OperationsDeploymentScreen() { return mine.length > 0 ? mine : crewed; }, [deployment, access, activeUnitId]); - const changeDay = (days: number) => { - const next = shiftDateKey(dateKey, days); + const selectDay = (next: string) => { dateRef.current = next; setDateKey(next); useOperationsStore.getState().setScope(useOperationsStore.getState().scope, next); @@ -182,11 +182,11 @@ export default function OperationsDeploymentScreen() { ) : null} {deployment && section !== 'mars' ? ( - changeDay(-1)} testID="operations-day-previous" accessibilityRole="button" accessibilityLabel={t('operations.previousDay')}> + selectDay(shiftDateKey(dateKey, -1))} testID="operations-day-previous" accessibilityRole="button" accessibilityLabel={t('operations.previousDay')}> - {dateKey} - changeDay(1)} testID="operations-day-next" accessibilityRole="button" accessibilityLabel={t('operations.nextDay')}> + + selectDay(shiftDateKey(dateKey, 1))} testID="operations-day-next" accessibilityRole="button" accessibilityLabel={t('operations.nextDay')}> diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 32ce1056..ee7cf0a5 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -1,5 +1,6 @@ /* eslint-disable react/react-in-jsx-scope */ import { Env } from '@env'; +import { type Href, useRouter } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -35,6 +36,7 @@ import { useUnitsStore } from '@/stores/units/store'; export default function Settings() { const { t } = useTranslation(); + const router = useRouter(); const { trackEvent } = useAnalytics(); const signOut = useAuthStore.getState().logout; const { colorScheme } = useColorScheme(); @@ -138,6 +140,8 @@ export default function Settings() { setShowServerUrl(true)} textStyle="text-info-600" /> setShowLoginInfo(true)} textStyle="text-info-600" /> setShowUnitSelection(true)} textStyle="text-info-600" /> + router.push('/account-security' as unknown as Href)} textStyle="text-info-600" /> + router.push('/login/shared-device' as unknown as Href)} textStyle="text-info-600" /> setShowLogoutConfirm(true)} textStyle="text-error-600" /> diff --git a/src/app/__tests__/root-layout.test.ts b/src/app/__tests__/root-layout.test.ts new file mode 100644 index 00000000..acb596a1 --- /dev/null +++ b/src/app/__tests__/root-layout.test.ts @@ -0,0 +1,52 @@ +import fs from 'fs'; +import path from 'path'; + +// A web sign-in popup comes back to the page its redirect names (/auth/callback here), and a production web build loads a +// route's module only when that route is shown, so the sign-in hooks' own module may never load in the popup. The root +// layout loads on every page: it must complete the popup. Every other import of the layout is stubbed, so this loads +// only the layout's own module code. +const stub = (): unknown => + new Proxy(function stubbed() {}, { + get: (_target, key) => { + if (key === '__esModule') return true; + if (key === Symbol.toPrimitive) return () => ''; + if (key === 'then' || typeof key === 'symbol') return undefined; + return stub(); + }, + apply: () => stub(), + construct: () => stub() as object, + }); + +const layoutImports = (layoutPath: string): string[] => { + const source = fs.readFileSync(layoutPath, 'utf8'); + const specifiers = new Set(); + for (const match of source.matchAll(/^\s*(?:import|export)\s+(?:type\s+)?(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/gm)) { + specifiers.add(match[1]); + } + return [...specifiers]; +}; + +describe('root layout', () => { + it('completes a pending web sign-in on whatever page the popup returns to', () => { + const layoutPath = path.join(__dirname, '..', '_layout.tsx'); + const maybeCompleteAuthSession = jest.fn(); + + // The setup file's mocks are already built, and a built mock wins over a later doMock until the registry is reset. + jest.resetModules(); + jest.isolateModules(() => { + for (const specifier of layoutImports(layoutPath)) { + if (specifier === 'react') continue; + const target = specifier.startsWith('.') ? path.resolve(path.dirname(layoutPath), specifier) : specifier; + const factory = specifier === 'expo-web-browser' ? () => ({ __esModule: true, maybeCompleteAuthSession }) : stub; + try { + jest.doMock(target, factory); + } catch { + jest.doMock(target, factory, { virtual: true }); + } + } + require(layoutPath); + }); + + expect(maybeCompleteAuthSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 20020534..f44e09ff 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -15,6 +15,7 @@ import * as Sentry from '@sentry/react-native'; import { isRunningInExpoGo } from 'expo'; import { DarkTheme, DefaultTheme, Stack, ThemeProvider, useNavigationContainerRef } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; +import * as WebBrowser from 'expo-web-browser'; import { useColorScheme } from 'nativewind'; import React, { useEffect } from 'react'; import { LogBox, Platform } from 'react-native'; @@ -39,6 +40,11 @@ import { loadBackgroundGeolocationState } from '@/lib/storage/background-geoloca import { uuidv4 } from '@/lib/utils'; import { appInitializationService } from '@/services/app-initialization.service'; +// A web sign-in popup returns to the page its redirect names (/auth/callback), and a production web build loads a route's +// module only when that route is shown, so the sign-in hooks' own completion may never run there. The root layout +// loads on every page, so the popup is completed here. On native this does nothing. +WebBrowser.maybeCompleteAuthSession(); + export { ErrorBoundary } from 'expo-router'; export const unstable_settings = { @@ -204,6 +210,9 @@ function RootLayout() { + + + diff --git a/src/app/login/__tests__/index.test.tsx b/src/app/login/__tests__/index.test.tsx index 9eeb0462..e05f02e3 100644 --- a/src/app/login/__tests__/index.test.tsx +++ b/src/app/login/__tests__/index.test.tsx @@ -148,6 +148,13 @@ jest.mock('@/lib/auth', () => ({ useAuth: () => mockUseAuth(), })); +// The second-factor sheet renders as a marker that says whether it is open. +jest.mock('@/components/auth/login-mfa-sheet', () => { + const React = require('react'); + const { View } = require('react-native'); + return { LoginMfaSheet: ({ isOpen }: { isOpen: boolean }) => (isOpen ? React.createElement(View, { testID: 'login-mfa-sheet-open' }) : null) }; +}); + jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), @@ -274,3 +281,27 @@ describe('Login', () => { expect(mockLogin).toHaveBeenCalledWith({ username: 'test', password: 'test' }); }); }); + +describe('the second-factor sheet on the login screen', () => { + const { default: useAuthStore } = jest.requireActual('@/stores/auth/store') as typeof import('@/stores/auth/store'); + const verify = (source: 'password' | 'sso') => ({ kind: 'verify' as const, methods: ['totp' as const], enrolled: ['totp' as const], preferred: 'totp' as const, expiresAt: null, source }); + + beforeEach(() => { + mockUseAuth.mockReturnValue({ login: jest.fn(), ssoLogin: jest.fn(), status: 'mfaRequired', error: null, isAuthenticated: false }); + mockUseAnalytics.mockReturnValue({ trackEvent: jest.fn() }); + }); + afterEach(() => useAuthStore.setState({ mfaChallenge: null })); + + it('opens for a password sign-in that continues on a login transaction', () => { + useAuthStore.setState({ mfaChallenge: verify('password') }); + render(); + expect(screen.getByTestId('login-mfa-sheet-open')).toBeTruthy(); + }); + + it("leaves a single sign-on's second factor to the SSO screen on top of it", () => { + // Both screens are mounted while the SSO screen is shown; two sheets would each stage their own setup key. + useAuthStore.setState({ mfaChallenge: verify('sso') }); + render(); + expect(screen.queryByTestId('login-mfa-sheet-open')).toBeNull(); + }); +}); diff --git a/src/app/login/__tests__/recovery.test.tsx b/src/app/login/__tests__/recovery.test.tsx new file mode 100644 index 00000000..41f4814b --- /dev/null +++ b/src/app/login/__tests__/recovery.test.tsx @@ -0,0 +1,78 @@ +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import * as loginMfa from '@/stores/auth/login-mfa'; +import useAuthStore from '@/stores/auth/store'; + +import FactorRecovery from '../recovery'; + +const mockReplace = jest.fn(); +jest.mock('expo-router', () => ({ Stack: { Screen: () => null }, useRouter: () => ({ replace: mockReplace }) })); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/components/ui', () => ({ FocusAwareStatusBar: () => null })); +jest.mock('expo-linking', () => ({ openURL: jest.fn() })); +jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn() })); +jest.mock('@/stores/auth/login-mfa', () => ({ + abandonFactorRecovery: jest.fn(), + finishFactorRecovery: jest.fn(), + hasLoginTransaction: jest.fn(() => true), + recoveryReplacementKey: jest.fn(), +})); + +const mocked = loginMfa as jest.Mocked; + +describe('"I lost my authenticator"', () => { + const beginFactorRecovery = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mocked.hasLoginTransaction.mockReturnValue(true); + useAuthStore.setState({ beginFactorRecovery }); + }); + + it('spends a recovery code, stages a new authenticator, removes the chosen passkeys, and shows the new codes once', async () => { + beginFactorRecovery.mockResolvedValue({ passkeys: [{ PasskeyId: 'pk-lost', DisplayName: 'Old phone' }], expiresAt: null }); + mocked.recoveryReplacementKey.mockResolvedValue({ SharedKey: 'NEW KEY', AuthenticatorUri: 'otpauth://x', ExpiresIn: 600 }); + mocked.finishFactorRecovery.mockResolvedValue({ recoveryCodes: ['n1', 'n2'] }); + + const { getByTestId, getAllByTestId } = render(); + fireEvent.changeText(getByTestId('recovery-code-input'), 'RC-1111'); + await act(async () => fireEvent.press(getByTestId('recovery-begin'))); + expect(beginFactorRecovery).toHaveBeenCalledWith('RC-1111'); + + await waitFor(() => expect(getByTestId('recovery-shared-key').props.children).toBe('NEW KEY')); + fireEvent.press(getByTestId('recovery-passkey-pk-lost')); + fireEvent.changeText(getByTestId('recovery-new-code'), '123456'); + await act(async () => fireEvent.press(getByTestId('recovery-finish'))); + expect(mocked.finishFactorRecovery).toHaveBeenCalledWith('123456', ['pk-lost']); + expect(getAllByTestId('mfa-recovery-code').map((n) => n.props.children)).toEqual(['n1', 'n2']); + + fireEvent.press(getByTestId('recovery-sign-in')); + expect(mockReplace).toHaveBeenCalledWith('/login'); + }); + + it('says who can help without a sign-in or a recovery code, and never waives anything', async () => { + mocked.hasLoginTransaction.mockReturnValue(false); + const { getByTestId, queryByTestId } = render(); + expect(queryByTestId('recovery-code-input')).toBeNull(); + fireEvent.press(getByTestId('recovery-back')); + expect(mockReplace).toHaveBeenCalledWith('/login'); + }); + + it('shows a wrong code and lets the member cancel the recovery', async () => { + beginFactorRecovery.mockResolvedValue({ passkeys: [], expiresAt: null }); + mocked.recoveryReplacementKey.mockResolvedValue({ SharedKey: 'K', AuthenticatorUri: 'otpauth://x', ExpiresIn: 600 }); + mocked.finishFactorRecovery.mockResolvedValue({ code: 'invalid_totp', ended: false }); + const { getByTestId } = render(); + fireEvent.changeText(getByTestId('recovery-code-input'), 'RC-1111'); + await act(async () => fireEvent.press(getByTestId('recovery-begin'))); + await waitFor(() => expect(getByTestId('recovery-shared-key')).toBeTruthy()); + fireEvent.changeText(getByTestId('recovery-new-code'), '000000'); + await act(async () => fireEvent.press(getByTestId('recovery-finish'))); + expect(getByTestId('recovery-error').props.children).toBe('mfa.errors.invalid_totp'); + + await act(async () => fireEvent.press(getByTestId('recovery-cancel'))); + expect(mocked.abandonFactorRecovery).toHaveBeenCalled(); + expect(mockReplace).toHaveBeenCalledWith('/login'); + }); +}); diff --git a/src/app/login/__tests__/shared-device.test.tsx b/src/app/login/__tests__/shared-device.test.tsx new file mode 100644 index 00000000..3c824407 --- /dev/null +++ b/src/app/login/__tests__/shared-device.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +import { readSharedInstallation, saveSharedInstallation, SHARED_INSTALLATION_STORAGE_KEY } from '@/lib/mfa/shared-installation'; +import { storage } from '@/lib/storage'; +import useAuthStore from '@/stores/auth/store'; + +import SharedDevice from '../shared-device'; + +const mockBack = jest.fn(); +const mockReplace = jest.fn(); +let mockCanGoBack = true; + +jest.mock('expo-router', () => ({ Stack: { Screen: () => null }, useRouter: () => ({ back: mockBack, replace: mockReplace, canGoBack: () => mockCanGoBack }) })); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/stores/auth/store', () => { + const { create } = require('zustand'); + return { __esModule: true, default: create(() => ({ status: 'signedOut' })) }; +}); + +describe('SharedDevice', () => { + beforeEach(() => { + jest.clearAllMocks(); + storage.delete(SHARED_INSTALLATION_STORAGE_KEY); + useAuthStore.setState({ status: 'signedOut' }); + mockCanGoBack = true; + }); + + it('suggests shared for a device nobody has set up, and saves its label', () => { + const { getByTestId } = render(); + // The label field shows only while "shared" is chosen. + expect(getByTestId('shared-device-label')).toBeTruthy(); + + fireEvent.changeText(getByTestId('shared-device-label'), 'Engine 12'); + fireEvent.press(getByTestId('shared-device-save')); + + expect(readSharedInstallation()).toEqual({ configured: true, shared: true, label: 'Engine 12' }); + expect(mockBack).toHaveBeenCalled(); + }); + + it('can be set up as a personal device', () => { + const { getByTestId, queryByTestId } = render(); + + fireEvent(getByTestId('shared-device-toggle'), 'valueChange', false); + expect(queryByTestId('shared-device-label')).toBeNull(); + fireEvent.press(getByTestId('shared-device-save')); + + expect(readSharedInstallation()).toEqual({ configured: true, shared: false, label: null }); + }); + + it('shows a personal device as it was saved', () => { + saveSharedInstallation({ shared: false, label: null }); + const { queryByTestId } = render(); + expect(queryByTestId('shared-device-label')).toBeNull(); + }); + + it('drops the label when shared mode is turned off', () => { + saveSharedInstallation({ shared: true, label: 'Engine 12' }); + mockCanGoBack = false; + const { getByTestId } = render(); + expect(getByTestId('shared-device-label').props.value).toBe('Engine 12'); + + fireEvent(getByTestId('shared-device-toggle'), 'valueChange', false); + fireEvent.press(getByTestId('shared-device-save')); + + expect(readSharedInstallation()).toEqual({ configured: true, shared: false, label: null }); + expect(mockReplace).toHaveBeenCalledWith('/login'); + }); + + it('says a change applies at the next sign-in when someone is signed in', () => { + useAuthStore.setState({ status: 'signedIn' }); + const { getByTestId } = render(); + expect(getByTestId('shared-device-next-sign-in')).toBeTruthy(); + }); +}); diff --git a/src/app/login/__tests__/sso.test.tsx b/src/app/login/__tests__/sso.test.tsx new file mode 100644 index 00000000..289e6462 --- /dev/null +++ b/src/app/login/__tests__/sso.test.tsx @@ -0,0 +1,251 @@ +import { act, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { saveSharedInstallation } from '@/lib/mfa/shared-installation'; + +import SsoLogin from '../sso'; + +const mockSsoLogin = jest.fn(); +const mockValidateSamlCallback = jest.fn(); +const mockStartSaml = jest.fn(); +const mockPromptAsync = jest.fn(); +let mockUrlHandler: ((event: { url: string }) => Promise) | null = null; + +// The screen builds its form schema with `import * as z`; the global zod mock only exports `z`, so give this test a +// chainable stand-in (the schema itself is not under test here). +jest.mock('zod', () => { + const chain: any = new Proxy(() => chain, { get: () => chain, apply: () => chain }); + return chain; +}); +jest.mock('@hookform/resolvers/zod', () => ({ zodResolver: () => undefined })); +jest.mock('expo-router', () => ({ useRouter: () => ({ push: jest.fn(), back: jest.fn() }) })); +jest.mock('expo-linking', () => ({ + addEventListener: jest.fn((_event: string, handler: (event: { url: string }) => Promise) => { + mockUrlHandler = handler; + return { remove: jest.fn() }; + }), + parse: jest.fn(() => ({ queryParams: {} })), +})); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/services/sso-discovery', () => ({ fetchSsoConfigForUser: jest.fn(() => Promise.resolve({ config: null, userExists: false })) })); +jest.mock('@/hooks/use-oidc-login', () => ({ + useOidcLogin: jest.fn(() => ({ request: null, response: null, promptAsync: mockPromptAsync, exchangeForResgridToken: jest.fn() })), +})); +jest.mock('@/hooks/use-saml-login', () => ({ + useSamlLogin: jest.fn(() => ({ + startSamlLogin: mockStartSaml, + isSamlCallback: (url: string) => url.includes('auth/callback') && url.includes('saml_response'), + validateSamlCallback: (url: string) => mockValidateSamlCallback(url), + })), +})); +jest.mock('@/lib/auth', () => ({ useAuth: () => ({ ssoLogin: mockSsoLogin, status: 'idle' }) })); +jest.mock('@/stores/auth/store', () => { + const { create } = require('zustand'); + return { __esModule: true, default: create(() => ({ error: null, isSsoMfaPending: false, mfaChallenge: null })) }; +}); +jest.mock('@/components/ui', () => ({ FocusAwareStatusBar: () => null, View: require('react-native').View })); +jest.mock('@/components/auth/login-mfa-sheet', () => ({ LoginMfaSheet: () => null })); +jest.mock('@/components/auth/login-otp-modal', () => ({ LoginOtpModal: () => null })); +jest.mock('@/components/ui/modal', () => { + const { View } = require('react-native'); + return { + Modal: ({ isOpen, children }: any) => (isOpen ? {children} : null), + ModalBackdrop: () => null, + ModalBody: ({ children }: any) => {children}, + ModalContent: ({ children }: any) => {children}, + ModalFooter: ({ children }: any) => {children}, + ModalHeader: ({ children }: any) => {children}, + }; +}); + +describe('SsoLogin SAML callback (login CSRF)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUrlHandler = null; + }); + + it('signs in only with a SAML response the RelayState check accepted', async () => { + mockValidateSamlCallback.mockResolvedValue({ samlResponse: 'relay-token', departmentToken: 'enc-dept' }); + render(); + + const url = 'resgridunit://auth/callback?saml_response=relay-token&department_token=enc-dept&relay_state=unit.nonce-1'; + await act(async () => mockUrlHandler!({ url })); + + expect(mockValidateSamlCallback).toHaveBeenCalledWith(url); + // The exchange names the department the relay sent; without it the server refuses the exchange. + expect(mockSsoLogin).toHaveBeenCalledWith({ provider: 'saml2', externalToken: 'relay-token', username: '', departmentToken: 'enc-dept' }); + expect(screen.queryByTestId('sso-error-modal')).toBeNull(); + }); + + it('refuses a callback this app did not start, and signs nobody in', async () => { + mockValidateSamlCallback.mockResolvedValue(null); + render(); + + await act(async () => mockUrlHandler!({ url: 'resgridunit://auth/callback?saml_response=attacker-token' })); + + expect(mockSsoLogin).not.toHaveBeenCalled(); + expect(screen.getByTestId('sso-error-modal')).toBeTruthy(); + }); + + it('ignores links that are not SAML callbacks', async () => { + render(); + + await act(async () => mockUrlHandler!({ url: 'resgridunit://calls/42' })); + + expect(mockValidateSamlCallback).not.toHaveBeenCalled(); + expect(mockSsoLogin).not.toHaveBeenCalled(); + }); +}); + +describe('SsoLogin SAML sign-in without the broker', () => { + const { fetchSsoConfigForUser } = jest.requireMock('@/services/sso-discovery') as { fetchSsoConfigForUser: jest.Mock }; + const start = 'https://api.example/api/v4/connect/saml-mobile-login?departmentToken=t'; + const saml = (overrides: Record = {}) => ({ + ssoEnabled: true, + providerType: 'saml2', + brokeredSsoAvailable: false, + samlLoginUrl: start, + departmentToken: 'dept-token', + ...overrides, + }); + + const pressSso = async () => { + render(); + // The field's own handlers: typing the username, then leaving the field, which looks the department up. + await act(async () => screen.getByPlaceholderText('login.username_placeholder').props.onChangeText('jdoe')); + await act(async () => screen.getByPlaceholderText('login.username_placeholder').props.onBlur()); + await act(async () => screen.UNSAFE_getByProps({ accessibilityLabel: 'login.sso_button' }).props.onPress()); + }; + + beforeEach(() => jest.clearAllMocks()); + + it("opens the server's SAML start page, which sends the browser on to the IdP", async () => { + fetchSsoConfigForUser.mockResolvedValue({ config: saml(), userExists: true }); + await pressSso(); + expect(mockStartSaml).toHaveBeenCalledWith(start); + expect(screen.queryByTestId('sso-error-modal')).toBeNull(); + }); + + it('says it cannot sign in where the server names no start page', async () => { + fetchSsoConfigForUser.mockResolvedValue({ config: saml({ samlLoginUrl: null }), userExists: true }); + await pressSso(); + expect(mockStartSaml).not.toHaveBeenCalled(); + expect(screen.getByTestId('sso-error-modal')).toBeTruthy(); + }); + + it('says it cannot sign in on the web edition, which the relay cannot return to', async () => { + const { Platform } = require('react-native'); + const os = Platform.OS; + Platform.OS = 'web'; + try { + fetchSsoConfigForUser.mockResolvedValue({ config: saml(), userExists: true }); + await pressSso(); + expect(mockStartSaml).not.toHaveBeenCalled(); + expect(screen.getByTestId('sso-error-modal')).toBeTruthy(); + } finally { + Platform.OS = os; + } + }); +}); + +describe('SsoLogin in the desktop app, without the broker', () => { + const { fetchSsoConfigForUser } = jest.requireMock('@/services/sso-discovery') as { fetchSsoConfigForUser: jest.Mock }; + const { Platform } = require('react-native'); + const os = Platform.OS; + const bridge = { legacySsoOidc: jest.fn(), legacySsoSaml: jest.fn(), legacySsoCancel: jest.fn(async () => undefined) }; + const oidcConfig = { ssoEnabled: true, providerType: 'oidc', brokeredSsoAvailable: false, authority: 'https://idp.example.com/tenant', clientId: 'resgrid-desktop', departmentToken: 'dept-token' }; + + const pressSso = async () => { + const view = render(); + await act(async () => screen.getByPlaceholderText('login.username_placeholder').props.onChangeText('jdoe')); + await act(async () => screen.getByPlaceholderText('login.username_placeholder').props.onBlur()); + await act(async () => screen.UNSAFE_getByProps({ accessibilityLabel: 'login.sso_button' }).props.onPress()); + return view; + }; + + beforeEach(() => { + jest.clearAllMocks(); + Platform.OS = 'web'; + (window as unknown as { electronAPI?: unknown }).electronAPI = bridge; + }); + + afterEach(() => { + Platform.OS = os; + delete (window as unknown as { electronAPI?: unknown }).electronAPI; + }); + + it("signs in with the id_token the main process redeemed, not the page's own popup", async () => { + fetchSsoConfigForUser.mockResolvedValue({ config: oidcConfig, userExists: true }); + bridge.legacySsoOidc.mockResolvedValue({ ok: true, idToken: 'idp.id.token' }); + + await pressSso(); + + expect(bridge.legacySsoOidc).toHaveBeenCalledWith('https://idp.example.com/tenant', 'resgrid-desktop', false); + expect(mockPromptAsync).not.toHaveBeenCalled(); + expect(mockSsoLogin).toHaveBeenCalledWith({ provider: 'oidc', externalToken: 'idp.id.token', username: 'jdoe', departmentToken: 'dept-token' }); + expect(screen.queryByTestId('sso-error-modal')).toBeNull(); + }); + + it('says nothing when the member abandons the sign-in, and shows the error when the provider refuses it', async () => { + fetchSsoConfigForUser.mockResolvedValue({ config: oidcConfig, userExists: true }); + bridge.legacySsoOidc.mockResolvedValueOnce({ ok: false, reason: 'cancelled' }); + const first = await pressSso(); + expect(mockSsoLogin).not.toHaveBeenCalled(); + expect(screen.queryByTestId('sso-error-modal')).toBeNull(); + first.unmount(); + + bridge.legacySsoOidc.mockResolvedValueOnce({ ok: false, reason: 'denied', code: 'access_denied' }); + await pressSso(); + expect(mockSsoLogin).not.toHaveBeenCalled(); + expect(screen.getByTestId('sso-error-modal')).toBeTruthy(); + }); + + it("starts SAML on the server's page and checks the relay's link the main process received", async () => { + const start = 'https://api.example/api/v4/connect/saml-mobile-login?departmentToken=t'; + const link = 'resgridunit://auth/callback?saml_response=relay-token&department_token=enc-dept&relay_state=unit.nonce-1'; + fetchSsoConfigForUser.mockResolvedValue({ config: { ssoEnabled: true, providerType: 'saml2', brokeredSsoAvailable: false, samlLoginUrl: start, departmentToken: 'dept-token' }, userExists: true }); + mockStartSaml.mockResolvedValue(link); + mockValidateSamlCallback.mockResolvedValue({ samlResponse: 'relay-token', departmentToken: 'enc-dept' }); + + await pressSso(); + + expect(mockStartSaml).toHaveBeenCalledWith(start); + expect(mockValidateSamlCallback).toHaveBeenCalledWith(link); + expect(mockSsoLogin).toHaveBeenCalledWith({ provider: 'saml2', externalToken: 'relay-token', username: 'jdoe', departmentToken: 'enc-dept' }); + expect(screen.queryByTestId('sso-error-modal')).toBeNull(); + }); + + it('refuses a relay link the RelayState check rejects, and says nothing when the SAML sign-in was abandoned', async () => { + fetchSsoConfigForUser.mockResolvedValue({ config: { ssoEnabled: true, providerType: 'saml2', brokeredSsoAvailable: false, samlLoginUrl: 'https://api.example/start', departmentToken: 'd' }, userExists: true }); + mockStartSaml.mockResolvedValueOnce(null); + const first = await pressSso(); + expect(mockValidateSamlCallback).not.toHaveBeenCalled(); + expect(screen.queryByTestId('sso-error-modal')).toBeNull(); + first.unmount(); + + mockStartSaml.mockResolvedValueOnce('resgridunit://auth/callback?saml_response=x&relay_state=unit.other'); + mockValidateSamlCallback.mockResolvedValueOnce(null); + await pressSso(); + expect(mockSsoLogin).not.toHaveBeenCalled(); + expect(screen.getByTestId('sso-error-modal')).toBeTruthy(); + }); + + it('asks the main process for a fresh provider sign-in on a shared installation', async () => { + saveSharedInstallation({ shared: true, label: null }); + try { + fetchSsoConfigForUser.mockResolvedValue({ config: oidcConfig, userExists: true }); + bridge.legacySsoOidc.mockResolvedValue({ ok: true, idToken: 'idp.id.token' }); + await pressSso(); + expect(bridge.legacySsoOidc).toHaveBeenCalledWith('https://idp.example.com/tenant', 'resgrid-desktop', true); + } finally { + saveSharedInstallation({ shared: false, label: null }); + } + }); + + it("ends a sign-in still waiting in the member's browser when the screen closes", () => { + const view = render(); + expect(bridge.legacySsoCancel).not.toHaveBeenCalled(); + view.unmount(); + expect(bridge.legacySsoCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/login/index.tsx b/src/app/login/index.tsx index 401fb7c4..dd1c92ee 100644 --- a/src/app/login/index.tsx +++ b/src/app/login/index.tsx @@ -1,8 +1,9 @@ -import { useRouter } from 'expo-router'; +import { type Href, useRouter } from 'expo-router'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { LoginFormProps } from '@/app/login/login-form'; +import { LoginMfaSheet } from '@/components/auth/login-mfa-sheet'; import { LoginOtpModal } from '@/components/auth/login-otp-modal'; import { ServerUrlBottomSheet } from '@/components/settings/server-url-bottom-sheet'; import { FocusAwareStatusBar } from '@/components/ui'; @@ -12,6 +13,9 @@ import { Text } from '@/components/ui/text'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuth } from '@/lib/auth'; import { logger } from '@/lib/logging'; +import { isMfaErrorCode, mfaErrorKey } from '@/lib/mfa/messages'; +import { useSharedInstallation } from '@/lib/mfa/shared-installation'; +import useAuthStore from '@/stores/auth/store'; import { LoginForm } from './login-form'; @@ -27,6 +31,11 @@ export default function Login() { const { trackEvent } = useAnalytics(); const router = useRouter(); const { login, status, error, isAuthenticated } = useAuth(); + const mfaChallenge = useAuthStore((s) => s.mfaChallenge); + const installation = useSharedInstallation(); + // A sign-in that ended (expired, too many attempts, policy changed) or a shared shift that ran out says why in the + // member's language. + const displayError = error === 'shift_ended' ? t('shared_session.shift_ended') : isMfaErrorCode(error) ? t(mfaErrorKey(error)) : error; // Track when login view is rendered useEffect(() => { @@ -68,7 +77,15 @@ export default function Login() { return ( <> - setShowServerUrl(true)} onSsoPress={() => router.push('/login/sso')} /> + setShowServerUrl(true)} + onSsoPress={() => router.push('/login/sso')} + onSharedDevicePress={() => router.push('/login/shared-device' as unknown as Href)} + sharedDevice={installation} + /> {t('login.errorModal.message')} + {displayError ? {displayError} : null} + ) : null} + {/* Language selector */} diff --git a/src/app/login/recovery.tsx b/src/app/login/recovery.tsx new file mode 100644 index 00000000..7e96c452 --- /dev/null +++ b/src/app/login/recovery.tsx @@ -0,0 +1,204 @@ +import * as Linking from 'expo-linking'; +import { Stack, useRouter } from 'expo-router'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { RecoveryCodes } from '@/components/mfa/recovery-codes'; +import { FocusAwareStatusBar } from '@/components/ui'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Checkbox, CheckboxIndicator, CheckboxLabel } from '@/components/ui/checkbox'; +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 { mfaErrorKey } from '@/lib/mfa/messages'; +import type { PasskeyData, TotpSetupData } from '@/lib/mfa/types'; +import { abandonFactorRecovery, finishFactorRecovery, hasLoginTransaction, recoveryReplacementKey } from '@/stores/auth/login-mfa'; +import useAuthStore from '@/stores/auth/store'; + +type Step = 'code' | 'replace' | 'done' | 'help'; + +/** + * "I lost my authenticator" (passkey plan sections 5.4 and 6.3). A recovery code, after the password or single sign-on, + * opens a restricted recovery: a new authenticator is staged and activated only when a code from it verifies, lost + * passkeys can be removed, and every session ends. Without a recovery code the page says who can help; nothing here + * waives MFA. + */ +export default function FactorRecovery() { + const { t } = useTranslation(); + const router = useRouter(); + const [step, setStep] = useState(() => (hasLoginTransaction() ? 'code' : 'help')); + const [code, setCode] = useState(''); + const [busy, setBusy] = useState(false); + const [errorCode, setErrorCode] = useState(null); + const [passkeys, setPasskeys] = useState([]); + const [remove, setRemove] = useState([]); + const [key, setKey] = useState(null); + const [recoveryCodes, setRecoveryCodes] = useState([]); + + useEffect(() => { + if (step !== 'replace' || key) { + return; + } + void recoveryReplacementKey().then((result) => ('SharedKey' in result ? setKey(result) : setErrorCode(result.code))); + }, [step, key]); + + const begin = useCallback(async () => { + setBusy(true); + setErrorCode(null); + const started = await useAuthStore.getState().beginFactorRecovery(code); + setBusy(false); + setCode(''); + if ('passkeys' in started) { + setPasskeys(started.passkeys); + setStep('replace'); + } else if (started.restart) { + setStep('help'); + setErrorCode(started.code); + } else { + setErrorCode(started.code); + } + }, [code]); + + const finish = useCallback(async () => { + setBusy(true); + setErrorCode(null); + const done = await finishFactorRecovery(code, remove); + setBusy(false); + setCode(''); + if ('recoveryCodes' in done) { + setRecoveryCodes(done.recoveryCodes); + setStep('done'); + } else { + setErrorCode(done.code); + if (done.ended) { + setStep('help'); + } + } + }, [code, remove]); + + const cancel = useCallback(async () => { + await abandonFactorRecovery(); + router.replace('/login'); + }, [router]); + + const togglePasskey = (id: string) => setRemove((current) => (current.includes(id) ? current.filter((x) => x !== id) : [...current, id])); + + return ( + <> + + + + + {t('mfa.recovery.title')} + + {step === 'code' ? ( + <> + {t('mfa.recovery.code_body')} + + + + + + + ) : 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/shared-device.tsx b/src/app/login/shared-device.tsx new file mode 100644 index 00000000..689cc2f0 --- /dev/null +++ b/src/app/login/shared-device.tsx @@ -0,0 +1,82 @@ +import { Stack, useRouter } from 'expo-router'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +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 { Switch } from '@/components/ui/switch'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { readSharedInstallation, saveSharedInstallation } from '@/lib/mfa/shared-installation'; +import useAuthStore from '@/stores/auth/store'; + +/** + * The shared vehicle device setting for this installation (passkey plan section 10.5), like Core Web's shared workstation + * page. A shared installation asks for a shared session at every sign-in: it locks when idle or put away, the next + * operator unlocks with their own second factor or ends the shift, and nothing is remembered between operators. It only + * ever tightens; the department can make sessions shared without it. + */ +export default function SharedDevice() { + const { t } = useTranslation(); + const router = useRouter(); + const signedIn = useAuthStore((s) => s.status === 'signedIn'); + const [initial] = useState(readSharedInstallation); + // Shared is the suggested choice for a device nobody has set up yet (plan section 12.5.2); personal stays one tap away. + const [shared, setShared] = useState(initial.configured ? initial.shared : true); + const [label, setLabel] = useState(initial.label ?? ''); + + const save = () => { + saveSharedInstallation({ shared, label: shared ? label : null }); + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/login'); + } + }; + + return ( + <> + + + + {t('shared_session.device_title')} + {t('shared_session.device_body')} + + {t('shared_session.device_toggle')} + + + {shared ? ( + + {t('shared_session.device_label')} + + + + + {t('shared_session.device_label_hint')} + + + ) : null} + {signedIn ? ( + + {t('shared_session.device_next_sign_in')} + + ) : null} + + + + + ); +} diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx index da2a8966..bc71bcd3 100644 --- a/src/app/login/sso.tsx +++ b/src/app/login/sso.tsx @@ -4,10 +4,11 @@ import { useRouter } from 'expo-router'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator } from 'react-native'; +import { ActivityIndicator, Platform } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import * as z from 'zod'; +import { LoginMfaSheet } from '@/components/auth/login-mfa-sheet'; import { LoginOtpModal } from '@/components/auth/login-otp-modal'; import { FocusAwareStatusBar, View } from '@/components/ui'; import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button'; @@ -21,6 +22,8 @@ import { useOidcLogin } from '@/hooks/use-oidc-login'; import { useSamlLogin } from '@/hooks/use-saml-login'; import { useAuth } from '@/lib/auth'; import { logger } from '@/lib/logging'; +import { desktopLegacySso } from '@/lib/mfa/legacy-sso-desktop'; +import { isSharedInstallation } from '@/lib/mfa/shared-installation'; import type { DepartmentSsoConfig } from '@/services/sso-discovery'; import { fetchSsoConfigForUser } from '@/services/sso-discovery'; import useAuthStore from '@/stores/auth/store'; @@ -48,6 +51,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 oidc = useOidcLogin({ authority: ssoConfig?.authority ?? '', @@ -104,6 +108,8 @@ export default function SsoLogin() { provider: 'oidc', externalToken: idToken, username: pendingUsernameRef.current, + // The exchange needs the department; discovery gave its encrypted token. + ...(ssoConfig?.departmentToken ? { departmentToken: ssoConfig.departmentToken } : {}), }); }) .catch(() => { @@ -113,14 +119,13 @@ export default function SsoLogin() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [oidc.response]); - // ── Deep-link handler for SAML callbacks ───────────────────────────────── - useEffect(() => { - const subscription = Linking.addEventListener('url', async ({ url }: { url: string }) => { - if (!isSamlCallback(url)) return; + // ── SAML callbacks: a deep link (mobile), or the desktop main process's answer ─ + const finishSamlCallback = useCallback( + async (url: string) => { // Validates the RelayState nonce — callbacks not belonging to a flow this // app initiated (login CSRF) are rejected here. - const samlResponse = await validateSamlCallback(url); - if (!samlResponse) { + const callback = await validateSamlCallback(url); + if (!callback) { setIsSsoLoading(false); setIsErrorModalVisible(true); return; @@ -128,15 +133,33 @@ export default function SsoLogin() { setIsSsoLoading(true); await ssoLogin({ provider: 'saml2', - externalToken: samlResponse, + externalToken: callback.samlResponse, username: pendingUsernameRef.current, + // The relay sends the department (encrypted) with the callback; the exchange needs it. + ...(callback.departmentToken ? { departmentToken: callback.departmentToken } : {}), }); + }, + [validateSamlCallback, ssoLogin] + ); + + useEffect(() => { + const subscription = Linking.addEventListener('url', async ({ url }: { url: string }) => { + if (!isSamlCallback(url)) return; + await finishSamlCallback(url); }); return () => subscription?.remove(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Leaving this screen ends a desktop sign-in still waiting in the member's browser. + useEffect( + () => () => { + void desktopLegacySso()?.legacySsoCancel(); + }, + [] + ); + // ── SSO lookup (called from username or departmentId blur) ─────────────── const triggerSsoLookup = useCallback(async (username: string, departmentIdStr?: string) => { if (username.trim().length < 3) return; @@ -168,16 +191,57 @@ export default function SsoLogin() { if (!ssoConfig) return; setIsSsoLoading(true); + if (ssoConfig.brokeredSsoAvailable) { + // 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. + // A refusal sets the store's error status, which opens the error modal above. + await useAuthStore.getState().loginWithBrokeredSso(ssoConfig.departmentToken ? { departmentToken: ssoConfig.departmentToken } : { username: pendingUsernameRef.current }); + setIsSsoLoading(false); + return; + } + + // The provider and the SAML relay return to this app's own scheme: the native app receives it, and in the desktop app + // the main process does (it also redeems the OIDC code there). A web page receives neither. + const desktop = desktopLegacySso(); if (ssoConfig.providerType === 'oidc') { + if (desktop) { + // A shared installation needs a fresh provider sign-in (see the OIDC hook). + const signedIn = await desktop.legacySsoOidc(ssoConfig.authority ?? '', ssoConfig.clientId ?? '', isSharedInstallation()); + if (!signedIn.ok) { + setIsSsoLoading(false); + if (signedIn.reason !== 'cancelled') { + setIsErrorModalVisible(true); + } + return; + } + // loading cleared by the status effects above + await ssoLogin({ + provider: 'oidc', + externalToken: signedIn.idToken, + username: pendingUsernameRef.current, + ...(ssoConfig.departmentToken ? { departmentToken: ssoConfig.departmentToken } : {}), + }); + return; + } await oidc.promptAsync(); // loading cleared by OIDC response useEffect - } else if (ssoConfig.providerType === 'saml2' && ssoConfig.idpSsoUrl) { - await startSamlLogin(ssoConfig.idpSsoUrl); + } else if (ssoConfig.providerType === 'saml2') { + // Without the broker, SAML starts on the server's page (an app cannot build the AuthnRequest). On a web page, or when + // the server names no start page, the member is told it cannot start. + if ((Platform.OS !== 'web' || desktop) && ssoConfig.samlLoginUrl) { + const returned = await startSamlLogin(ssoConfig.samlLoginUrl); + if (returned) { + await finishSamlCallback(returned); + return; + } + } else { + setIsErrorModalVisible(true); + } setIsSsoLoading(false); } else { setIsSsoLoading(false); } - }, [ssoConfig, oidc, startSamlLogin]); + }, [ssoConfig, oidc, startSamlLogin, ssoLogin, finishSamlCallback]); const showSsoButton = ssoConfig?.ssoEnabled === true; @@ -311,6 +375,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.notificationsTitle', title: 'Instant Notifications', + descriptionKey: 'onboarding.notificationsDescription', description: 'Receive immediate alerts for emergencies and important updates from your department', - icon: , + icon: , }, { + titleKey: 'onboarding.callsTitle', title: 'Interact with Calls', + descriptionKey: 'onboarding.callsDescription', description: 'Seamlessly view call information and interact with your team members for efficient emergency response', - icon: , + icon: , }, ]; -const SLIDE_COUNT = SLIDES.length; -const SPRING_CONFIG = { damping: 25, stiffness: 200 }; - -const OnboardingSlide: React.FC = ({ title, description, icon, slideWidth }) => ( - - {icon} - {title} - - {description} - - -); - -const Pagination: React.FC<{ currentIndex: number; length: number }> = ({ currentIndex, length }) => ( - - {Array.from({ length }).map((_, index) => ( - - ))} - -); - export default function Onboarding() { - const [_, setIsFirstTime] = useIsFirstTime(); + const { t } = useTranslation(); + const [, setIsFirstTime] = useIsFirstTime(); const setIsOnboarding = useAuthStore((state) => state.setIsOnboarding); const router = useRouter(); const [currentIndex, setCurrentIndex] = useState(0); - const [containerWidthState, setContainerWidthState] = useState(0); - const containerWidth = useSharedValue(0); - const { colorScheme } = useColorScheme(); - - // Shared value tracks slide position as an index offset (e.g. 0 = first slide, 1 = second) - const slideOffset = useSharedValue(0); - const buttonOpacity = useSharedValue(0); - const startOffset = useSharedValue(0); useEffect(() => { setIsOnboarding(); }, [setIsOnboarding]); - const updateIndex = useCallback( - (index: number) => { - setCurrentIndex(index); - buttonOpacity.value = index === SLIDE_COUNT - 1 ? withTiming(1, { duration: 500 }) : withTiming(0, { duration: 300 }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - - // Sync shared value → JS state whenever the slide settles - useAnimatedReaction( - () => Math.round(slideOffset.value), - (result, previous) => { - if (result !== previous && result >= 0 && result < SLIDE_COUNT) { - runOnJS(updateIndex)(result); - } - } - ); - - // Swipe gesture: pan horizontally, snap to nearest slide on release - const panGesture = Gesture.Pan() - .activeOffsetX([-15, 15]) - .onStart(() => { - 'worklet'; - startOffset.value = slideOffset.value; - }) - .onUpdate((event) => { - 'worklet'; - const dragIndex = -event.translationX / containerWidth.value; - const target = startOffset.value + dragIndex; - slideOffset.value = Math.max(-0.3, Math.min(SLIDE_COUNT - 1 + 0.3, target)); - }) - .onEnd(() => { - 'worklet'; - const snapped = Math.round(slideOffset.value); - const clamped = Math.max(0, Math.min(SLIDE_COUNT - 1, snapped)); - slideOffset.value = withSpring(clamped, SPRING_CONFIG); - }); - - const nextSlide = useCallback(() => { - const next = Math.min(currentIndex + 1, SLIDE_COUNT - 1); - slideOffset.value = withSpring(next, SPRING_CONFIG); - }, [currentIndex, slideOffset]); - - const skip = useCallback(() => { - setIsFirstTime(false); - router.replace('/login'); - }, [setIsFirstTime, router]); - const finish = useCallback(() => { setIsFirstTime(false); router.replace('/login'); }, [setIsFirstTime, router]); - const handleContainerLayout = useCallback( - (event: { nativeEvent: { layout: { width: number } } }) => { - const w = event.nativeEvent.layout.width; - containerWidth.value = w; - setContainerWidthState(w); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - - // The entire carousel translates on X - const carouselStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: -slideOffset.value * containerWidth.value }], - })); - - const buttonAnimatedStyle = useAnimatedStyle(() => ({ - opacity: buttonOpacity.value, - transform: [{ translateY: (1 - buttonOpacity.value) * 20 }], - })); - - if (containerWidthState === 0) { - // Render only the outer container to measure width before laying out slides - return ( - - - ); - } - + const slide = SLIDES[currentIndex]; return ( - - + setCurrentIndex((index) => Math.min(index + 1, SLIDES.length - 1))} + onSlideChange={setCurrentIndex} + /> ); } 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..f3152a8c --- /dev/null +++ b/src/components/auth/__tests__/login-mfa-sheet.test.tsx @@ -0,0 +1,147 @@ +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('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..1f39102f --- /dev/null +++ b/src/components/auth/login-mfa-sheet.tsx @@ -0,0 +1,282 @@ +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); + const result = await action(); + setBusy(false); + if (!result.ok) { + setErrorCode(result.code ?? 'unknown_error'); + } + }, []); + + 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 f60a8668..d7919ed5 100644 --- a/src/components/checklists/__tests__/checklist-run-sheet.test.tsx +++ b/src/components/checklists/__tests__/checklist-run-sheet.test.tsx @@ -1,3 +1,4 @@ +jest.mock('@/components/common/native-modal', () => ({ NativeModal: require('react-native').Modal })); jest.mock('@/stores/data-protection/store', () => ({ dataProtectionStore: { getState: () => ({ isStepUpActive: () => false }) } })); jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); jest.mock('expo-crypto', () => ({ randomUUID: () => 'image-id' })); @@ -7,13 +8,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 })); -// NativeModal carries a toast host; toast layering is covered by the toast-container suite. -jest.mock('@/components/toast/toast-container', () => ({ ModalToastHost: () => null })); -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'; @@ -23,32 +33,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 60555c1f..c6a1e320 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 { NativeModal } from '@/components/common/native-modal'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; @@ -133,7 +134,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..e5be4c26 --- /dev/null +++ b/src/components/common/__tests__/date-time-field.test.tsx @@ -0,0 +1,95 @@ +jest.mock('@/components/common/native-modal', () => ({ NativeModal: require('react-native').Modal })); +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(); +}); + +it('commits the highlighted day of the month shown, never a day in a month that was navigated away from', () => { + const onChange = jest.fn(); + const screen = render(); + fireEvent.press(screen.getByTestId('date')); + fireEvent.press(screen.getByTestId('date-next')); + expect(screen.getByTestId('date-day-29').props.accessibilityState).toEqual({ selected: true }); + fireEvent.press(screen.getByTestId('date-done')); + expect(onChange).toHaveBeenLastCalledWith('2024-02-29'); + + fireEvent.press(screen.getByTestId('date')); + fireEvent.press(screen.getByTestId('date-previous')); + fireEvent.press(screen.getByTestId('date-month')); + fireEvent.press(screen.getByTestId('date-month-1')); + fireEvent.press(screen.getByTestId('date-year')); + fireEvent.press(screen.getByTestId('date-year-2020')); + fireEvent.press(screen.getByTestId('date-done')); + expect(onChange).toHaveBeenLastCalledWith('2020-02-28'); + 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..2e374f37 --- /dev/null +++ b/src/components/common/date-time-field.tsx @@ -0,0 +1,207 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { NativeModal } from '@/components/common/native-modal'; +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; +} + +// 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)); + // The calendar always shows the draft's month, so Done commits the day that is highlighted. + const month = draft.getMonth(); + const year = draft.getFullYear(); + const [view, setView] = useState<'days' | 'months' | 'years'>('days'); + const [yearPage, setYearPage] = useState(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); + setYearPage(Math.max(1, Math.min(9976, 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); + // Moves the draft to another month, keeping its day (or the month's last day) and its time. + const showMonth = (nextYear: number, nextMonth: number) => { + const next = new Date(draft); + next.setFullYear(nextYear, nextMonth, 1); + if (next.getFullYear() < 1 || next.getFullYear() > 9999) return; + const end = new Date(next); + end.setMonth(next.getMonth() + 1, 0); + next.setDate(Math.min(draft.getDate(), end.getDate())); + setDraft(next); + }; + const move = (direction: number) => { + if (view === 'years') { + setYearPage((page) => Math.max(1, Math.min(9976, page + direction * 24))); + return; + } + showMonth(year, month + direction); + }; + 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}`, + () => { + showMonth(number, month); + setView('days'); + }, + year === number + )} + + ))} + + ) : view === 'months' ? ( + + {Array.from({ length: 12 }, (_, number) => ( + + {choice( + new Date(2024, number, 1).toLocaleDateString(locale, { month: 'short' }), + `month-${number}`, + () => { + showMonth(year, 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..3a6f843d --- /dev/null +++ b/src/components/data-protection/__tests__/step-up-modal.test.tsx @@ -0,0 +1,124 @@ +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('withdraws a request Responder has not decided when the modal 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 screen = render(); + await act(async () => fireEvent.press(screen.getByTestId('step-up-approval'))); + expect(store.waitForApproval).toHaveBeenCalledWith('ap-1', expect.anything()); + + await act(async () => screen.rerender()); + expect(store.cancelApproval).toHaveBeenCalledTimes(1); + expect(store.cancelApproval).toHaveBeenCalledWith('ap-1'); + expect(store.completeApproval).not.toHaveBeenCalled(); + screen.unmount(); + }); + + it('starts one request for a double tap, and withdraws it when the modal closed before it started', async () => { + let start: (value: { id: string; number: string }) => void = () => undefined; + store.requestApproval.mockImplementation( + () => + new Promise((resolve) => { + start = resolve; + }) + ); + const onClose = jest.fn(); + const screen = render(); + act(() => { + fireEvent.press(screen.getByTestId('step-up-approval')); + fireEvent.press(screen.getByTestId('step-up-approval')); + }); + expect(store.requestApproval).toHaveBeenCalledTimes(1); + + screen.rerender(); + await act(async () => start({ id: 'ap-2', number: '7' })); + expect(store.cancelApproval).toHaveBeenCalledWith('ap-2'); + expect(store.waitForApproval).not.toHaveBeenCalled(); + screen.unmount(); + }); + + 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..991bf654 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,84 @@ 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 approvalAbort = useRef(null); + // The request Responder has not decided yet: withdrawn on the server when the member cancels or the modal closes. + const pendingApprovalId = useRef(null); + const cancelApproval = useCallback(() => { + const pending = pendingApprovalId.current; + approvalAbort.current?.abort(); + approvalAbort.current = null; + pendingApprovalId.current = null; + setApproval(null); + if (pending) { + void dataProtectionStore.getState().cancelApproval(pending); + } + }, []); + + // 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(''); + cancelApproval(); } - }, [isOpen]); + }, [isOpen, cancelApproval]); + + 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 tap while one is starting or waiting would orphan the first. + if (approvalAbort.current) { + return; + } + const controller = new AbortController(); + approvalAbort.current = controller; + const store = dataProtectionStore.getState(); + const started = await store.requestApproval(); + if (!started || controller.signal.aborted) { + if (started) { + // Closed while the request was being made: withdraw it, since nobody is waiting for the answer. + void store.cancelApproval(started.id); + } + if (approvalAbort.current === controller) { + approvalAbort.current = null; + } + return; + } + pendingApprovalId.current = started.id; + setApproval(started); + const decided = await store.waitForApproval(started.id, controller.signal); + if (decided === 'aborted' || controller.signal.aborted) { + return; + } + // Decided: nothing is left to withdraw, and another request may start. + pendingApprovalId.current = null; + approvalAbort.current = null; + setApproval(null); + if (decided !== 'approved') { + dataProtectionStore.setState({ lastError: decided === 'denied' ? 'approval_denied' : 'approval_expired' }); + return; + } + finished(await store.completeApproval(started.id)); + }, [finished]); + + const offersCode = methods == null || methods.includes('totp'); const handleVerify = useCallback(async () => { const submitted = code.trim(); @@ -61,8 +135,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 +153,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 +203,11 @@ export const StepUpModal: React.FC = ({ isOpen, onClose, onVer - + {offersCode && !approval ? ( + + ) : null} diff --git a/src/components/mfa/__tests__/account-verify-modal.test.tsx b/src/components/mfa/__tests__/account-verify-modal.test.tsx new file mode 100644 index 00000000..c5298b9e --- /dev/null +++ b/src/components/mfa/__tests__/account-verify-modal.test.tsx @@ -0,0 +1,72 @@ +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 { AccountVerifyModal } from '../account-verify-modal'; + +jest.mock('react-i18next', () => ({ 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..6a3d9999 --- /dev/null +++ b/src/components/onboarding/__tests__/onboarding-screen.test.tsx @@ -0,0 +1,93 @@ +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 new slide from the top of the page', () => { + const screen = render(); + const scrollTo = screen.UNSAFE_getByType(ScrollView).instance.scrollTo as jest.Mock; + scrollTo.mockClear(); + screen.rerender(); + expect(scrollTo).toHaveBeenCalledWith({ y: 0, animated: false }); + scrollTo.mockClear(); + mockWindow = { width: 844, height: 390, fontScale: 1, scale: 1 }; + 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..ffae44f1 --- /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 across slides, so each new slide starts from 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..ebfb2f40 --- /dev/null +++ b/src/components/operations/__tests__/time-report-picker.test.tsx @@ -0,0 +1,57 @@ +jest.mock('@/components/common/native-modal', () => ({ NativeModal: require('react-native').Modal })); +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..3bfef7c1 --- /dev/null +++ b/src/components/records/__tests__/record-date-picker.test.tsx @@ -0,0 +1,23 @@ +jest.mock('@/components/common/native-modal', () => ({ NativeModal: require('react-native').Modal })); +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(