diff --git a/client/package.json b/client/package.json index 1bb971287..4d116aa1c 100644 --- a/client/package.json +++ b/client/package.json @@ -53,6 +53,7 @@ "@fontsource/poppins": "^5.2.7", "@mapbox/mapbox-gl-rtl-text": "^0.4.0", "@maplibre/maplibre-gl-leaflet": "0.1.4", + "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@trek/shared": "*", "axios": "^1.6.7", diff --git a/client/src/App.tsx b/client/src/App.tsx index 0df993c00..5dfff295c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -40,6 +40,7 @@ import { managedRoutes } from './managed' // for a reload. const PluginPage = lazyWithRetry(() => import('./pages/PluginPage')) const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage')) +const NativeHandoffPage = lazyWithRetry(() => import('./pages/NativeHandoffPage')) const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage')) const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage')) const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage')) @@ -397,6 +398,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} } /> diff --git a/client/src/api/nativeAuth.ts b/client/src/api/nativeAuth.ts new file mode 100644 index 000000000..c17705a7d --- /dev/null +++ b/client/src/api/nativeAuth.ts @@ -0,0 +1,12 @@ +import type { NativeExchangeRequest, NativeHandoffResponse } from '@trek/shared' +import { apiClient } from './client' + +/** The two halves of the sign-in hand-off between system browser and app. */ +export const nativeAuthApi = { + /** Browser side: trade a signed-in session for a one-time code bound to `challenge`. */ + handoff: (challenge: string): Promise => + apiClient.post('/auth/native/handoff', { challenge }).then((r) => r.data), + /** App side: redeem the code with the verifier; the response sets the session cookie. */ + exchange: (body: NativeExchangeRequest): Promise => + apiClient.post('/auth/native/exchange', body).then(() => undefined), +} diff --git a/client/src/components/Budget/useBudgetPanel.ts b/client/src/components/Budget/useBudgetPanel.ts index e8105d573..692edff22 100644 --- a/client/src/components/Budget/useBudgetPanel.ts +++ b/client/src/components/Budget/useBudgetPanel.ts @@ -10,6 +10,7 @@ import { currencyDecimals } from '../../utils/formatters' import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD, hasCustomMemberSplit } from './BudgetPanel.helpers' import { PIE_COLORS } from './BudgetPanel.constants' import type { TripMember } from './BudgetPanelMemberChips' +import { downloadBlob } from '../../utils/fileDownload' function useIsDark(): boolean { const [dark, setDark] = useState(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')) @@ -183,13 +184,8 @@ export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) { const bom = '' const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim() - a.download = `budget-${safeName}.csv` - a.click() - URL.revokeObjectURL(url) + downloadBlob(blob, `budget-${safeName}.csv`) } const th: CSSProperties = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' } diff --git a/client/src/components/Collections/collectionFile.test.ts b/client/src/components/Collections/collectionFile.test.ts index 20a4f9a81..657f7232b 100644 --- a/client/src/components/Collections/collectionFile.test.ts +++ b/client/src/components/Collections/collectionFile.test.ts @@ -75,10 +75,10 @@ describe('downloads', () => { expect(click).toHaveBeenCalledTimes(1) expect(names).toEqual(['lisbon' + COLLECTION_FILE_EXTENSION]) expect(created[0].type).toBe('application/json') - expect(document.querySelector('a')).toBeNull() expect(revoke).not.toHaveBeenCalled() vi.runAllTimers() expect(revoke).toHaveBeenCalledWith('blob:x') + expect(document.querySelector('a')).toBeNull() }) it('FE-COLL-FILE-014: hands the browser a GPX under the list name', async () => { diff --git a/client/src/components/Collections/collectionFile.ts b/client/src/components/Collections/collectionFile.ts index 94dc6714a..b0b6487c9 100644 --- a/client/src/components/Collections/collectionFile.ts +++ b/client/src/components/Collections/collectionFile.ts @@ -6,6 +6,7 @@ import { type CollectionGpxProblem, type CollectionGpxReadResult, } from '@trek/shared' +import { downloadBlob } from '../../utils/fileDownload' /** * The browser half of list export and import (#2198, #2301). @@ -37,16 +38,7 @@ export function collectionFileName(name: string, extension = COLLECTION_FILE_EXT } function download(content: string, type: string, fileName: string): void { - const url = URL.createObjectURL(new Blob([content], { type })) - const a = document.createElement('a') - a.href = url - a.download = fileName - document.body.appendChild(a) - a.click() - a.remove() - // Revoked on the next tick: revoking synchronously can cancel the download - // in some browsers before it has started reading the blob. - setTimeout(() => URL.revokeObjectURL(url), 0) + downloadBlob(new Blob([content], { type }), fileName) } /** Hand the file to the browser as a download. */ diff --git a/client/src/components/Planner/TripExportModal.tsx b/client/src/components/Planner/TripExportModal.tsx index ba91783d7..502ed1cb3 100644 --- a/client/src/components/Planner/TripExportModal.tsx +++ b/client/src/components/Planner/TripExportModal.tsx @@ -7,6 +7,7 @@ import { useToast } from '../shared/Toast' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' import { useSettingsStore } from '../../store/settingsStore' import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' +import { downloadBlob } from '../../utils/fileDownload' /** * What a GPX download can carry. Worded by what someone wants on their device @@ -67,18 +68,6 @@ export function TripExportModal({ const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, tripId) const fileBase = trip?.title || 'trip' - // Shared tail of every download: Firefox and Safari cancel the download when - // the object URL is revoked before they picked the blob up, hence the delay. - const saveBlob = (blob: Blob, filename: string) => { - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100) - } - const exportPdf = async () => { if (busy) return setBusy('pdf') @@ -107,7 +96,7 @@ export function TripExportModal({ try { const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' }) if (!res.ok) throw new Error() - saveBlob(await res.blob(), `${fileBase}.ics`) + downloadBlob(await res.blob(), `${fileBase}.ics`) onClose() } catch { toast.error(t('planner.icsExportFailed')) @@ -125,7 +114,7 @@ export function TripExportModal({ // "nothing happened" and "the download broke" look identical otherwise. if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return } if (!res.ok) throw new Error() - saveBlob(await res.blob(), `${fileBase}.gpx`) + downloadBlob(await res.blob(), `${fileBase}.gpx`) onClose() } catch { toast.error(t('dayplan.gpxFailed')) diff --git a/client/src/components/Settings/AccountTab.test.tsx b/client/src/components/Settings/AccountTab.test.tsx index 11bd84931..af7af5102 100644 --- a/client/src/components/Settings/AccountTab.test.tsx +++ b/client/src/components/Settings/AccountTab.test.tsx @@ -598,7 +598,7 @@ describe('AccountTab – Backup codes', () => { expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); expect(downloadName).toBe('trek-mfa-backup-codes.txt'); - expect(revokeObjectURL).toHaveBeenCalledWith('blob:codes'); + await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:codes')); expect(document.querySelector('a[download]')).toBeNull(); createObjectURL.mockRestore(); diff --git a/client/src/components/Settings/AccountTab.tsx b/client/src/components/Settings/AccountTab.tsx index 126d296d6..88ec2e951 100644 --- a/client/src/components/Settings/AccountTab.tsx +++ b/client/src/components/Settings/AccountTab.tsx @@ -10,6 +10,9 @@ import { getApiErrorMessage } from '../../types' import type { UserWithOidc } from '../../types' import Section from './Section' import PasskeysSection from './PasskeysSection' +import NativeServerSection from './NativeServerSection' +import { isNativeApp } from '../../native/platform' +import { downloadBlob } from '../../utils/fileDownload' const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' @@ -100,15 +103,7 @@ export default function AccountTab(): React.ReactElement { const downloadBackupCodes = () => { if (!backupCodesText) return - const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'trek-mfa-backup-codes.txt' - document.body.appendChild(a) - a.click() - a.remove() - URL.revokeObjectURL(url) + downloadBlob(new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' }), 'trek-mfa-backup-codes.txt') } const printBackupCodes = () => { @@ -160,6 +155,7 @@ export default function AccountTab(): React.ReactElement { return ( <> +
@@ -405,8 +401,10 @@ export default function AccountTab(): React.ReactElement {
- {/* Passkeys */} - + {/* Passkeys. Not in the app: WebAuthn in its WebView needs a domain + association no build can have for every server, so they are managed + in the browser (signing in with one works through it too). */} + {!isNativeApp() && } {/* Avatar */}
diff --git a/client/src/components/Settings/NativeServerSection.test.tsx b/client/src/components/Settings/NativeServerSection.test.tsx new file mode 100644 index 000000000..c8c51a994 --- /dev/null +++ b/client/src/components/Settings/NativeServerSection.test.tsx @@ -0,0 +1,33 @@ +// FE-SETTINGS-NATIVE-SERVER-001 to FE-SETTINGS-NATIVE-SERVER-004 +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '../../../tests/helpers/render' +import NativeServerSection from './NativeServerSection' +import MSettingsServer from '../../mobile/screens/settings/MSettingsServer' +import { nativeServerHost } from '../../native/platform' +import { TrekShell } from '../../native/trekShell' + +vi.mock('../../native/platform', () => ({ nativeServerHost: vi.fn() })) +vi.mock('../../native/trekShell', () => ({ TrekShell: { resetServer: vi.fn() } })) + +beforeEach(() => { + vi.mocked(TrekShell.resetServer).mockReset().mockResolvedValue() +}) + +describe.each([ + ['desktop', NativeServerSection], + ['mobile', MSettingsServer], +])('the %s server section', (_shell, Section) => { + it('renders nothing in a browser', () => { + vi.mocked(nativeServerHost).mockReturnValue(null) + const { container } = render(
) + expect(container).toBeEmptyDOMElement() + }) + + it('names the server and leads back to the address screen', () => { + vi.mocked(nativeServerHost).mockReturnValue('trek.example.com') + render(
) + expect(screen.getByText('This app is connected to trek.example.com.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Change server' })) + expect(TrekShell.resetServer).toHaveBeenCalled() + }) +}) diff --git a/client/src/components/Settings/NativeServerSection.tsx b/client/src/components/Settings/NativeServerSection.tsx new file mode 100644 index 000000000..845d06da8 --- /dev/null +++ b/client/src/components/Settings/NativeServerSection.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { Server } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { nativeServerHost } from '../../native/platform' +import { TrekShell } from '../../native/trekShell' +import Section from './Section' + +/** Only inside the iOS/Android app: which server it talks to, and the way to another one. */ +export default function NativeServerSection(): React.ReactElement | null { + const { t } = useTranslation() + const host = nativeServerHost() + if (!host) return null + return ( +
+

{t('native.settings.serverHint', { server: host })}

+ +
+ ) +} diff --git a/client/src/components/Studio/spreadFile.ts b/client/src/components/Studio/spreadFile.ts index 3ee22d9f9..8d09f6852 100644 --- a/client/src/components/Studio/spreadFile.ts +++ b/client/src/components/Studio/spreadFile.ts @@ -1,5 +1,6 @@ import type { BookPageSetup, BookSpread } from '@trek/shared' import { bookElementSchema } from '@trek/shared' +import { downloadBlob } from '../../utils/fileDownload' /** * A spread as a file, so a design can leave the book it was made in. @@ -202,16 +203,7 @@ export function importSpread(raw: unknown, page: BookPageSetup): BookSpread | nu /** Hand the file to the browser as a download. */ export function downloadSpread(spread: BookSpread, page: BookPageSetup, name: string) { const json = JSON.stringify(exportSpread(spread, page, name), null, 2) - const url = URL.createObjectURL(new Blob([json], { type: 'application/json' })) - const a = document.createElement('a') - a.href = url - a.download = `${slug(name) || 'spread'}.trekspread.json` - document.body.appendChild(a) - a.click() - a.remove() - // Revoked on the next tick: revoking synchronously can cancel the download - // in some browsers before it has started reading the blob. - setTimeout(() => URL.revokeObjectURL(url), 0) + downloadBlob(new Blob([json], { type: 'application/json' }), `${slug(name) || 'spread'}.trekspread.json`) } function slug(name: string): string { diff --git a/client/src/main.tsx b/client/src/main.tsx index 7d06410e9..4309aa4ee 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -36,6 +36,7 @@ import { startConnectivityProbe } from './sync/connectivity' import { requestPersistentStorage } from './sync/persistentStorage' import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary' import { installGlobalErrorHandlers } from './utils/globalErrorHandlers' +import { startNativeApp } from './native/nativeApp' maybeInstallTouchDragPolyfill() startConnectivityProbe() @@ -43,6 +44,8 @@ startConnectivityProbe() requestPersistentStorage() // Event handlers and async code never reach a boundary; this is where they land. installGlobalErrorHandlers() +// Inside the iOS/Android app: splash, back button, status bar, offline start (iOS). +startNativeApp() ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/client/src/mobile/screens/settings/MSettings.tsx b/client/src/mobile/screens/settings/MSettings.tsx index d6f5f438d..26da3f553 100644 --- a/client/src/mobile/screens/settings/MSettings.tsx +++ b/client/src/mobile/screens/settings/MSettings.tsx @@ -28,6 +28,7 @@ import MSettingsNotifications from './MSettingsNotifications' import MSettingsIntegrations from './MSettingsIntegrations' import MSettingsAccount from './MSettingsAccount' import MSettingsAbout from './MSettingsAbout' +import MSettingsServer from './MSettingsServer' interface SectionTab { id: string @@ -122,6 +123,7 @@ export default function MSettings() { {/* Per-plugin settings still reuse the existing responsive tab. */} {active.id === 'plugins' && hasPlugins && } {active.id === 'offline' && } + {active.id === 'account' && } {active.id === 'account' && } {active.id === 'about' && appVersion && } diff --git a/client/src/mobile/screens/settings/MSettingsAccount.tsx b/client/src/mobile/screens/settings/MSettingsAccount.tsx index caa559e66..1fd6c6845 100644 --- a/client/src/mobile/screens/settings/MSettingsAccount.tsx +++ b/client/src/mobile/screens/settings/MSettingsAccount.tsx @@ -27,6 +27,8 @@ import { getApiErrorMessage } from '../../../types' import type { UserWithOidc } from '../../../types' import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi' import MConfirmSheet from './MConfirmSheet' +import { downloadBlob } from '../../../utils/fileDownload' +import { isNativeApp } from '../../../native/platform' const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' @@ -130,15 +132,7 @@ export default function MSettingsAccount() { } const downloadBackupCodes = () => { - const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'trek-mfa-backup-codes.txt' - document.body.appendChild(a) - a.click() - a.remove() - URL.revokeObjectURL(url) + downloadBlob(new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' }), 'trek-mfa-backup-codes.txt') } const printBackupCodes = () => { @@ -502,7 +496,8 @@ export default function MSettingsAccount() { )} - + {/* Not in the app: see the passkeys note in components/Settings/AccountTab.tsx. */} + {!isNativeApp() && } + {t('native.settings.serverHint', { server: host })} + { void TrekShell.resetServer() }}>{t('native.changeServer')} + + ) +} diff --git a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx index cea7d4409..9235e8409 100644 --- a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx @@ -9,6 +9,7 @@ import { useTranslation } from '../../../../i18n' import { INNER_CLS, TileHeader } from './MTripSheetUi' import type { MTripSheetsProps } from '../MTripShell' import type { LucideIcon } from 'lucide-react' +import { downloadBlob } from '../../../../utils/fileDownload' /** * Export sheet ('export', opened from the Mehr sheet): the desktop day-plan @@ -71,16 +72,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) { try { const res = await fetch(`/api/trips/${planner.tripId}/export.ics`, { credentials: 'include' }) if (!res.ok) throw new Error() - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${planner.trip?.title || 'trip'}.ics` - document.body.appendChild(a) - a.click() - // Firefox/Safari cancel the download when the object URL is revoked - // before they picked the blob up. - setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100) + downloadBlob(await res.blob(), `${planner.trip?.title || 'trip'}.ics`) shell.closeSheet() } catch { planner.toast.error(t('planner.icsExportFailed')) @@ -99,14 +91,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) { const res = await fetch(`/api/trips/${planner.tripId}/places/export.gpx`, { credentials: 'include' }) if (res.status === 404) { planner.toast.info(t('dayplan.gpxEmpty')); return } if (!res.ok) throw new Error() - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${planner.trip?.title || 'trip'}.gpx` - document.body.appendChild(a) - a.click() - setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100) + downloadBlob(await res.blob(), `${planner.trip?.title || 'trip'}.gpx`) shell.closeSheet() } catch { planner.toast.error(t('dayplan.gpxFailed')) diff --git a/client/src/native/browserSignIn.test.ts b/client/src/native/browserSignIn.test.ts new file mode 100644 index 000000000..624b04c97 --- /dev/null +++ b/client/src/native/browserSignIn.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { sha256 } from '@noble/hashes/sha2' +import { bytesToHex } from '@noble/hashes/utils' +import { BrowserSignInCancelled, createHandoffSecret, handoffUrl, signInThroughBrowser } from './browserSignIn' +import { TrekShell } from './trekShell' +import { nativeAuthApi } from '../api/nativeAuth' + +vi.mock('./trekShell', () => ({ APP_URL_SCHEME: 'com.liketrek.trek', TrekShell: { authenticate: vi.fn() } })) +vi.mock('../api/nativeAuth', () => ({ nativeAuthApi: { exchange: vi.fn() } })) + +beforeEach(() => { + vi.mocked(TrekShell.authenticate).mockReset() + vi.mocked(nativeAuthApi.exchange).mockReset().mockResolvedValue() +}) + +describe('createHandoffSecret', () => { + it('pairs a fresh 64-character verifier with the hex sha256 the server checks against', () => { + const { verifier, challenge } = createHandoffSecret() + expect(verifier).toMatch(/^[0-9a-f]{64}$/) + expect(challenge).toBe(bytesToHex(sha256(new TextEncoder().encode(verifier)))) + expect(createHandoffSecret().verifier).not.toBe(verifier) + }) +}) + +describe('handoffUrl', () => { + it('points at the hand-off page with the challenge and an optional start', () => { + expect(handoffUrl('https://trek.example.com', 'c')).toBe('https://trek.example.com/native-handoff?challenge=c') + expect(handoffUrl('https://trek.example.com', 'c', 'oidc')).toBe('https://trek.example.com/native-handoff?challenge=c&start=oidc') + }) +}) + +describe('signInThroughBrowser', () => { + it('opens the hand-off page and redeems the returned code with the verifier', async () => { + vi.mocked(TrekShell.authenticate).mockResolvedValue({ url: 'com.liketrek.trek://auth?code=abc' }) + + await signInThroughBrowser('oidc') + + const opened = new URL(vi.mocked(TrekShell.authenticate).mock.calls[0]![0].url) + expect(opened.pathname).toBe('/native-handoff') + expect(opened.searchParams.get('start')).toBe('oidc') + expect(vi.mocked(TrekShell.authenticate).mock.calls[0]![0].callbackScheme).toBe('com.liketrek.trek') + const { code, verifier } = vi.mocked(nativeAuthApi.exchange).mock.calls[0]![0] + expect(code).toBe('abc') + expect(bytesToHex(sha256(new TextEncoder().encode(verifier)))).toBe(opened.searchParams.get('challenge')) + }) + + it('turns a closed browser into BrowserSignInCancelled', async () => { + vi.mocked(TrekShell.authenticate).mockRejectedValue(Object.assign(new Error('closed'), { code: 'CANCELLED' })) + await expect(signInThroughBrowser()).rejects.toBeInstanceOf(BrowserSignInCancelled) + expect(nativeAuthApi.exchange).not.toHaveBeenCalled() + }) + + it('passes other failures on', async () => { + vi.mocked(TrekShell.authenticate).mockRejectedValue(new Error('no browser')) + await expect(signInThroughBrowser()).rejects.toThrow('no browser') + }) + + it('refuses a return without a code', async () => { + vi.mocked(TrekShell.authenticate).mockResolvedValue({ url: 'com.liketrek.trek://auth' }) + await expect(signInThroughBrowser()).rejects.toThrow('without a sign-in code') + expect(nativeAuthApi.exchange).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/native/browserSignIn.ts b/client/src/native/browserSignIn.ts new file mode 100644 index 000000000..029afde1d --- /dev/null +++ b/client/src/native/browserSignIn.ts @@ -0,0 +1,61 @@ +import { sha256 } from '@noble/hashes/sha2' +import { bytesToHex } from '@noble/hashes/utils' +import { nativeAuthApi } from '../api/nativeAuth' +import { APP_URL_SCHEME, TrekShell } from './trekShell' + +/** + * SSO and passkeys cannot run inside the app's WebView: identity providers + * refuse embedded browsers, and WebAuthn wants the app to be associated with + * the server's domain, which no build can be for every server. So the app + * opens the server's /native-handoff page in the system browser, lets the user + * sign in there, and carries the session back with a one-time code. + * + * The verifier never leaves this function. Only its sha256 travels into the + * browser, and the code that comes back through the URL scheme is useless to + * anyone who does not also hold the verifier. The hash is computed in JS + * because crypto.subtle is missing on a plain-http server, which is how many + * home networks reach theirs. + */ + +export const HANDOFF_PATH = '/native-handoff' + +export type BrowserSignInStart = 'oidc' + +export function createHandoffSecret(): { verifier: string; challenge: string } { + const bytes = crypto.getRandomValues(new Uint8Array(32)) + const verifier = bytesToHex(bytes) + return { verifier, challenge: bytesToHex(sha256(new TextEncoder().encode(verifier))) } +} + +export function handoffUrl(origin: string, challenge: string, start?: BrowserSignInStart, invite?: string): string { + const url = new URL(HANDOFF_PATH, origin) + url.searchParams.set('challenge', challenge) + if (start) url.searchParams.set('start', start) + if (invite) url.searchParams.set('invite', invite) + return url.href +} + +export class BrowserSignInCancelled extends Error { + constructor() { + super('Sign-in cancelled') + this.name = 'BrowserSignInCancelled' + } +} + +/** Resolves once the WebView holds a session cookie of its own. */ +export async function signInThroughBrowser(start?: BrowserSignInStart, invite?: string): Promise { + const { verifier, challenge } = createHandoffSecret() + let returned: string + try { + ;({ url: returned } = await TrekShell.authenticate({ + url: handoffUrl(window.location.origin, challenge, start, invite), + callbackScheme: APP_URL_SCHEME, + })) + } catch (err) { + if ((err as { code?: string })?.code === 'CANCELLED') throw new BrowserSignInCancelled() + throw err + } + const code = new URL(returned).searchParams.get('code') + if (!code) throw new Error('The browser returned without a sign-in code') + await nativeAuthApi.exchange({ code, verifier }) +} diff --git a/client/src/native/nativeApp.test.ts b/client/src/native/nativeApp.test.ts new file mode 100644 index 000000000..795d0e11e --- /dev/null +++ b/client/src/native/nativeApp.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { App } from '@capacitor/app' +import { SplashScreen } from '@capacitor/splash-screen' +import { StatusBar, Style } from '@capacitor/status-bar' +import { startNativeApp } from './nativeApp' +import { isNativeApp, isNativeIos } from './platform' +import { keepOfflineShellFresh } from './offlineShell' +import { isEffectivelyOffline } from '../sync/networkMode' + +vi.mock('@capacitor/app', () => ({ App: { addListener: vi.fn(), minimizeApp: vi.fn() } })) +vi.mock('@capacitor/splash-screen', () => ({ SplashScreen: { hide: vi.fn() } })) +vi.mock('@capacitor/status-bar', () => ({ StatusBar: { setStyle: vi.fn() }, Style: { Dark: 'DARK', Light: 'LIGHT' } })) +vi.mock('./platform', () => ({ isNativeApp: vi.fn(), isNativeIos: vi.fn() })) +vi.mock('./offlineShell', () => ({ keepOfflineShellFresh: vi.fn() })) +vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn() })) + +function backButtonHandler(): (event: { canGoBack: boolean }) => void { + const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'backButton') + return call![1] as unknown as (event: { canGoBack: boolean }) => void +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + vi.mocked(isNativeApp).mockReturnValue(true) + vi.mocked(isNativeIos).mockReturnValue(false) + vi.mocked(isEffectivelyOffline).mockReturnValue(false) + vi.mocked(keepOfflineShellFresh).mockResolvedValue() + document.documentElement.classList.remove('dark') +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('startNativeApp', () => { + it('does nothing in a browser', () => { + vi.mocked(isNativeApp).mockReturnValue(false) + startNativeApp() + expect(App.addListener).not.toHaveBeenCalled() + expect(StatusBar.setStyle).not.toHaveBeenCalled() + }) + + it('hides the splash after the first frame', () => { + startNativeApp() + vi.runOnlyPendingTimers() + expect(SplashScreen.hide).toHaveBeenCalled() + }) + + it('walks back through history before it leaves the app', () => { + const back = vi.spyOn(window.history, 'back').mockImplementation(() => {}) + startNativeApp() + backButtonHandler()({ canGoBack: true }) + expect(back).toHaveBeenCalled() + backButtonHandler()({ canGoBack: false }) + expect(App.minimizeApp).toHaveBeenCalled() + }) + + it('keeps the status bar icons readable when the scheme changes', async () => { + startNativeApp() + expect(StatusBar.setStyle).toHaveBeenLastCalledWith({ style: Style.Light }) + document.documentElement.classList.add('dark') + await vi.waitFor(() => expect(StatusBar.setStyle).toHaveBeenLastCalledWith({ style: Style.Dark })) + }) + + it('refreshes the offline start on iOS once the first screen has settled', () => { + vi.mocked(isNativeIos).mockReturnValue(true) + startNativeApp() + expect(keepOfflineShellFresh).not.toHaveBeenCalled() + vi.advanceTimersByTime(15_000) + expect(keepOfflineShellFresh).toHaveBeenCalled() + }) + + it('skips the refresh while offline, and never on Android', () => { + vi.mocked(isNativeIos).mockReturnValue(true) + vi.mocked(isEffectivelyOffline).mockReturnValue(true) + startNativeApp() + vi.advanceTimersByTime(15_000) + expect(keepOfflineShellFresh).not.toHaveBeenCalled() + + vi.mocked(isNativeIos).mockReturnValue(false) + vi.mocked(isEffectivelyOffline).mockReturnValue(false) + startNativeApp() + vi.advanceTimersByTime(15_000) + expect(keepOfflineShellFresh).not.toHaveBeenCalled() + }) + + it('logs a failed refresh instead of throwing', async () => { + vi.mocked(isNativeIos).mockReturnValue(true) + vi.mocked(keepOfflineShellFresh).mockRejectedValue(new Error('down')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + startNativeApp() + vi.advanceTimersByTime(15_000) + await vi.waitFor(() => expect(warn).toHaveBeenCalled()) + }) +}) diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts new file mode 100644 index 000000000..efc4b4121 --- /dev/null +++ b/client/src/native/nativeApp.ts @@ -0,0 +1,49 @@ +import { App } from '@capacitor/app' +import { SplashScreen } from '@capacitor/splash-screen' +import { StatusBar, Style } from '@capacitor/status-bar' +import { isNativeApp, isNativeIos } from './platform' +import { keepOfflineShellFresh } from './offlineShell' +import { isEffectivelyOffline } from '../sync/networkMode' + +/** Long enough that the warm-up never competes with the first screen's requests. */ +const OFFLINE_SHELL_DELAY_MS = 15_000 + +/** + * What the UI does differently when it runs inside the iOS or Android app. + * Everything else is the same UI a browser gets from this server. + */ +export function startNativeApp(): void { + if (!isNativeApp()) return + + followThemeInStatusBar() + + // Android's back gesture: through the app's own history first, and only + // then out of the app, the way a browser tab would behave. + void App.addListener('backButton', ({ canGoBack }) => { + if (canGoBack) window.history.back() + else void App.minimizeApp() + }) + + requestAnimationFrame(() => { void SplashScreen.hide() }) + + if (isNativeIos()) { + window.setTimeout(() => { + if (isEffectivelyOffline()) return + keepOfflineShellFresh().catch((err) => console.warn('[native] offline start page not refreshed', err)) + }, OFFLINE_SHELL_DELAY_MS) + } +} + +/** + * Light icons on the dark scheme and dark icons on the light one. Observing + * the class keeps applyAppearance() the only writer of , and it catches + * every way the scheme changes, including "follow the system". + */ +function followThemeInStatusBar(): void { + const root = document.documentElement + const apply = (): void => { + void StatusBar.setStyle({ style: root.classList.contains('dark') ? Style.Dark : Style.Light }) + } + apply() + new MutationObserver(apply).observe(root, { attributes: true, attributeFilter: ['class'] }) +} diff --git a/client/src/native/nativeFiles.test.ts b/client/src/native/nativeFiles.test.ts new file mode 100644 index 000000000..ddd72ff24 --- /dev/null +++ b/client/src/native/nativeFiles.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Directory, Filesystem } from '@capacitor/filesystem' +import { Share } from '@capacitor/share' +import { cacheFileName, fileNameFromUrl, shareFile } from './nativeFiles' + +vi.mock('@capacitor/filesystem', () => ({ Directory: { Cache: 'CACHE' }, Filesystem: { writeFile: vi.fn() } })) +vi.mock('@capacitor/share', () => ({ Share: { share: vi.fn() } })) + +beforeEach(() => { + vi.mocked(Filesystem.writeFile).mockReset().mockResolvedValue({ uri: 'file:///cache/trip.pdf' }) + vi.mocked(Share.share).mockReset().mockResolvedValue({}) +}) + +describe('cacheFileName', () => { + it('keeps a readable name but nothing that could leave the cache directory', () => { + expect(cacheFileName('Lisbon 2026.pdf')).toBe('Lisbon 2026.pdf') + expect(cacheFileName('../../etc/passwd')).toBe('_.._etc_passwd') + expect(cacheFileName('a:b*c?"d"|f\u0001.txt')).toBe('a_b_c__d__e__f_.txt') + expect(cacheFileName('...hidden')).toBe('hidden') + expect(cacheFileName(' ')).toBe('download') + }) +}) + +describe('fileNameFromUrl', () => { + it('takes the decoded last path segment', () => { + expect(fileNameFromUrl('/api/trips/1/files/42/Boarding%20pass.pdf?token=x')).toBe('Boarding pass.pdf') + expect(fileNameFromUrl('/uploads/files/')).toBe('files') + expect(fileNameFromUrl('/bad/%E0%A4%A')).toBe('%E0%A4%A') + }) +}) + +describe('shareFile', () => { + it('writes the blob to the cache and hands it to the share sheet', async () => { + await shareFile(new Blob(['hello'], { type: 'text/plain' }), 'note.txt') + + expect(Filesystem.writeFile).toHaveBeenCalledWith({ path: 'note.txt', data: btoa('hello'), directory: Directory.Cache }) + expect(Share.share).toHaveBeenCalledWith({ files: ['file:///cache/trip.pdf'] }) + }) + + it('treats a dismissed sheet as the user changing their mind', async () => { + vi.mocked(Share.share).mockRejectedValue(new Error('Share canceled')) + await expect(shareFile(new Blob(['x']), 'x.txt')).resolves.toBeUndefined() + }) + + it('passes a real failure on', async () => { + vi.mocked(Share.share).mockRejectedValue(new Error('No activity found')) + await expect(shareFile(new Blob(['x']), 'x.txt')).rejects.toThrow('No activity found') + }) +}) diff --git a/client/src/native/nativeFiles.ts b/client/src/native/nativeFiles.ts new file mode 100644 index 000000000..260549c31 --- /dev/null +++ b/client/src/native/nativeFiles.ts @@ -0,0 +1,46 @@ +import { Directory, Filesystem } from '@capacitor/filesystem' +import { Share } from '@capacitor/share' + +function toBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result).slice(String(reader.result).indexOf(',') + 1)) + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(blob) + }) +} + +/** Readable, but safe as a single path segment inside the cache directory. */ +export function cacheFileName(filename: string): string { + const cleaned = Array.from(filename, (ch) => (ch.charCodeAt(0) < 32 || '\\/:*?"<>|'.includes(ch) ? '_' : ch)).join('').trim() + return cleaned.replace(/^\.+/, '') || 'download' +} + +export function fileNameFromUrl(url: string): string { + const last = url.split('?')[0]!.split('/').filter(Boolean).pop() ?? '' + try { + return decodeURIComponent(last) + } catch { + return last + } +} + +/** + * The app's WebView cannot save a download on its own, so the file goes into + * the app's cache and from there into the system share sheet, which offers + * saving it to Files, opening it in another app or sending it on. + */ +export async function shareFile(blob: Blob, filename: string): Promise { + const { uri } = await Filesystem.writeFile({ + path: cacheFileName(filename), + data: await toBase64(blob), + directory: Directory.Cache, + }) + try { + await Share.share({ files: [uri] }) + } catch (err) { + // Dismissing the sheet rejects; that is the user's choice, not a failure. + if (/cancel/i.test(String((err as Error)?.message))) return + throw err + } +} diff --git a/client/src/native/offlineShell.test.ts b/client/src/native/offlineShell.test.ts new file mode 100644 index 000000000..ef3c55c70 --- /dev/null +++ b/client/src/native/offlineShell.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { keepOfflineShellFresh, manifestFiles } from './offlineShell' +import { TrekShell } from './trekShell' + +vi.mock('./trekShell', () => ({ TrekShell: { refreshOfflineCopy: vi.fn() } })) + +const manifest = { + 'index.html': { file: 'assets/index-a1b2c3d4.js', css: ['assets/index-e5f6a7b8.css'], assets: ['assets/poppins-11223344.woff2'] }, + 'src/pages/AtlasPage.tsx': { file: 'assets/AtlasPage-99887766.js', css: ['assets/index-e5f6a7b8.css'] }, + _shared: { file: 'assets/shared-55443322.js', assets: 'not-a-list' }, + broken: null, +} + +beforeEach(() => { + vi.mocked(TrekShell.refreshOfflineCopy).mockReset().mockResolvedValue({ saved: true }) +}) + +describe('manifestFiles', () => { + it('collects every chunk, stylesheet and asset once', () => { + expect(manifestFiles(manifest).sort()).toEqual([ + 'assets/AtlasPage-99887766.js', + 'assets/index-a1b2c3d4.js', + 'assets/index-e5f6a7b8.css', + 'assets/poppins-11223344.woff2', + 'assets/shared-55443322.js', + ]) + }) + + it('returns nothing for something that is not a manifest', () => { + expect(manifestFiles(null)).toEqual([]) + expect(manifestFiles('x')).toEqual([]) + }) +}) + +describe('keepOfflineShellFresh', () => { + it('refreshes the stored start page and fetches every file of the build', async () => { + const fetched: string[] = [] + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url === '/asset-manifest.json') { + expect(init).toEqual({ cache: 'no-store' }) + return new Response(JSON.stringify(manifest)) + } + fetched.push(url) + if (url.includes('shared')) throw new Error('offline for a moment') + return new Response('x') + }) as unknown as typeof fetch + + await keepOfflineShellFresh(fetchImpl) + + expect(TrekShell.refreshOfflineCopy).toHaveBeenCalled() + expect(fetched.sort()).toEqual([ + '/assets/AtlasPage-99887766.js', + '/assets/index-a1b2c3d4.js', + '/assets/index-e5f6a7b8.css', + '/assets/poppins-11223344.woff2', + '/assets/shared-55443322.js', + ]) + }) + + it('stops after the start page when the build has no manifest', async () => { + const fetchImpl = vi.fn(async () => new Response('', { status: 404 })) as unknown as typeof fetch + await keepOfflineShellFresh(fetchImpl) + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) +}) diff --git a/client/src/native/offlineShell.ts b/client/src/native/offlineShell.ts new file mode 100644 index 000000000..8fb95355f --- /dev/null +++ b/client/src/native/offlineShell.ts @@ -0,0 +1,51 @@ +import { TrekShell } from './trekShell' + +/** + * iOS only. WKWebView runs no service worker for a domain the app does not + * list at build time, so the app stands in for it: the native side keeps a + * copy of the start page (TrekShellPlugin.refreshOfflineCopy), and this pulls + * every chunk of the current build into WebKit's HTTP cache. The server marks + * them immutable, so a cold start without network can load the stored start + * page under the server's origin and find all its scripts, including the lazy + * routes nobody has opened yet. + */ + +const MANIFEST_URL = '/asset-manifest.json' +const CONCURRENCY = 4 + +interface ManifestChunk { + file?: string + css?: string[] + assets?: string[] +} + +export function manifestFiles(manifest: unknown): string[] { + if (typeof manifest !== 'object' || manifest === null) return [] + const files = new Set() + for (const chunk of Object.values(manifest as Record)) { + if (typeof chunk?.file === 'string') files.add(chunk.file) + for (const list of [chunk?.css, chunk?.assets]) { + if (Array.isArray(list)) list.forEach((f) => typeof f === 'string' && files.add(f)) + } + } + return [...files] +} + +async function warmCache(files: string[], fetchFile: (path: string) => Promise): Promise { + const queue = [...files] + const worker = async (): Promise => { + for (let path = queue.shift(); path !== undefined; path = queue.shift()) { + // One missing chunk must not stop the rest; the next launch tries again. + await fetchFile(`/${path}`).catch(() => undefined) + } + } + await Promise.all(Array.from({ length: CONCURRENCY }, worker)) +} + +export async function keepOfflineShellFresh(fetchImpl: typeof fetch = fetch): Promise { + await TrekShell.refreshOfflineCopy() + const response = await fetchImpl(MANIFEST_URL, { cache: 'no-store' }) + if (!response.ok) return + const files = manifestFiles(await response.json()) + await warmCache(files, (path) => fetchImpl(path).then((r) => r.blob())) +} diff --git a/client/src/native/platform.ts b/client/src/native/platform.ts new file mode 100644 index 000000000..af0adddf5 --- /dev/null +++ b/client/src/native/platform.ts @@ -0,0 +1,15 @@ +import { Capacitor } from '@capacitor/core' + +/** True inside the iOS and Android apps, where the WebView shows this server's UI. */ +export function isNativeApp(): boolean { + return Capacitor.isNativePlatform() +} + +export function isNativeIos(): boolean { + return isNativeApp() && Capacitor.getPlatform() === 'ios' +} + +/** The server the app is connected to: the WebView sits on its origin. */ +export function nativeServerHost(): string | null { + return isNativeApp() ? window.location.host : null +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 9c8dff1b3..52789744d 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -16,7 +16,6 @@ import ToggleSwitch from '../components/Settings/ToggleSwitch'; import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'; import { useLogin } from './login/useLogin'; import LoginWorld from './login/LoginWorld'; -import { clearSignedOut } from '../utils/signedOut' /** Fixed so the sky does not reshuffle on every render. */ const STARFIELD = [ @@ -82,6 +81,9 @@ export default function LoginPage(): React.ReactElement { handleDemoLogin, handleSubmit, handlePasskeyLogin, + nativeApp, + handleSsoClick, + changeServer, } = useLogin(); const oidcButtonShown = !!(appConfig?.oidc_configured && appConfig?.oidc_login && !oidcOnly); @@ -497,7 +499,7 @@ export default function LoginPage(): React.ReactElement {
)} )} + + {nativeApp && ( + + )} diff --git a/client/src/pages/NativeHandoffPage.test.tsx b/client/src/pages/NativeHandoffPage.test.tsx new file mode 100644 index 000000000..13297c298 --- /dev/null +++ b/client/src/pages/NativeHandoffPage.test.tsx @@ -0,0 +1,53 @@ +// FE-NATIVE-HANDOFF-PAGE-001 to FE-NATIVE-HANDOFF-PAGE-004 +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '../../tests/helpers/render' +import NativeHandoffPage from './NativeHandoffPage' +import { useNativeHandoff } from './nativeHandoff/useNativeHandoff' + +vi.mock('./nativeHandoff/useNativeHandoff', () => ({ useNativeHandoff: vi.fn() })) + +const base = { + valid: true, + ready: true, + userName: 'maria', + status: 'idle' as const, + openApp: vi.fn(), + switchAccount: vi.fn(), +} + +beforeEach(() => { + vi.mocked(useNativeHandoff).mockReturnValue({ ...base }) +}) + +describe('NativeHandoffPage', () => { + it('FE-NATIVE-HANDOFF-PAGE-001: asks before handing the session to the app', () => { + render() + expect(screen.getByText('You are signed in as maria. Open the TREK app to finish signing in there.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Open the TREK app' })) + expect(base.openApp).toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Use a different account' })) + expect(base.switchAccount).toHaveBeenCalled() + }) + + it('FE-NATIVE-HANDOFF-PAGE-002: shows the failure and keeps the button while sending disabled', () => { + vi.mocked(useNativeHandoff).mockReturnValue({ ...base, status: 'failed' }) + const { rerender } = render() + expect(screen.getByRole('alert')).toHaveTextContent('Signing in did not work. Please try again.') + + vi.mocked(useNativeHandoff).mockReturnValue({ ...base, status: 'sending' }) + rerender() + expect(screen.getByRole('button', { name: 'Open the TREK app' })).toBeDisabled() + }) + + it('FE-NATIVE-HANDOFF-PAGE-003: spins while the session is being checked', () => { + vi.mocked(useNativeHandoff).mockReturnValue({ ...base, ready: false }) + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('FE-NATIVE-HANDOFF-PAGE-004: says so when the link is broken', () => { + vi.mocked(useNativeHandoff).mockReturnValue({ ...base, valid: false, ready: false }) + render() + expect(screen.getByText('Signing in did not work. Please try again.')).toBeInTheDocument() + }) +}) diff --git a/client/src/pages/NativeHandoffPage.tsx b/client/src/pages/NativeHandoffPage.tsx new file mode 100644 index 000000000..db8f73bfd --- /dev/null +++ b/client/src/pages/NativeHandoffPage.tsx @@ -0,0 +1,38 @@ +import { Smartphone } from 'lucide-react' +import { useTranslation } from '../i18n' +import { useNativeHandoff } from './nativeHandoff/useNativeHandoff' + +export default function NativeHandoffPage() { + const { t } = useTranslation() + const { valid, ready, userName, status, openApp, switchAccount } = useNativeHandoff() + + return ( +
+
+ + {!valid ? ( +

{t('native.login.failed')}

+ ) : !ready ? ( +
+ ) : ( + <> +

{t('native.handoff.title')}

+

{t('native.handoff.hint', { name: userName })}

+ {status === 'failed' &&

{t('native.login.failed')}

} + + + + )} +
+
+ ) +} diff --git a/client/src/pages/login/useLogin.native.test.tsx b/client/src/pages/login/useLogin.native.test.tsx new file mode 100644 index 000000000..136f97353 --- /dev/null +++ b/client/src/pages/login/useLogin.native.test.tsx @@ -0,0 +1,137 @@ +// FE-LOGIN-NATIVE-001 to FE-LOGIN-NATIVE-008: useLogin inside the iOS/Android app +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act, waitFor } from '../../../tests/helpers/render'; +import { MemoryRouter } from 'react-router'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import { resetAllStores } from '../../../tests/helpers/store'; +import { buildAppConfig } from '../../../tests/helpers/factories'; +import { TranslationProvider } from '../../i18n/TranslationContext'; +import { useAuthStore } from '../../store/authStore'; +import { startAuthentication } from '@simplewebauthn/browser'; +import { markSignedOut, wasSignedOut } from '../../utils/signedOut'; +import { BrowserSignInCancelled, signInThroughBrowser } from '../../native/browserSignIn'; +import { TrekShell } from '../../native/trekShell'; +import { useLogin } from './useLogin'; + +// Stable across renders: useLogin's config effect depends on navigate. +const mockNavigate = vi.fn(); +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return { ...actual, useNavigate: () => mockNavigate }; +}); +vi.mock('@simplewebauthn/browser', () => ({ startAuthentication: vi.fn() })); +vi.mock('../../native/platform', () => ({ isNativeApp: () => true })); +vi.mock('../../native/browserSignIn', async () => { + const actual = await vi.importActual('../../native/browserSignIn'); + return { ...actual, signInThroughBrowser: vi.fn() }; +}); +vi.mock('../../native/trekShell', () => ({ TrekShell: { resetServer: vi.fn() } })); + +const realLocation = window.location; +const loadUser = vi.fn(async () => {}); + +function renderLogin() { + return renderHook(() => useLogin(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); +} + +function clickEvent() { + return { preventDefault: vi.fn() } as unknown as React.MouseEvent; +} + +beforeEach(() => { + resetAllStores(); + vi.mocked(signInThroughBrowser).mockReset(); + vi.mocked(startAuthentication).mockReset(); + vi.mocked(TrekShell.resetServer).mockReset(); + loadUser.mockClear(); + useAuthStore.setState({ loadUser }); + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { ...realLocation, pathname: '/login', href: 'http://localhost/login', search: '' }, + }); +}); + +afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(window, 'location', { configurable: true, writable: true, value: realLocation }); +}); + +describe('useLogin in the native app', () => { + it('FE-LOGIN-NATIVE-001: reports that it runs in the app', async () => { + const { result } = renderLogin(); + await waitFor(() => expect(result.current.appConfig).not.toBeNull()); + expect(result.current.nativeApp).toBe(true); + }); + + it('FE-LOGIN-NATIVE-002: does not bounce an OIDC-only install to the IdP inside the WebView', async () => { + server.use( + http.get('/api/auth/app-config', () => + HttpResponse.json(buildAppConfig({ password_login: false, oidc_configured: true, oidc_login: true })), + ), + ); + const { result } = renderLogin(); + await waitFor(() => expect(result.current.appConfig).not.toBeNull()); + expect(window.location.href).toBe('http://localhost/login'); + expect(result.current.oidcOnly).toBe(true); + }); + + it('FE-LOGIN-NATIVE-003: routes the SSO link through the system browser and signs in', async () => { + vi.mocked(signInThroughBrowser).mockResolvedValue(); + markSignedOut(); + const { result } = renderLogin(); + const event = clickEvent(); + + await act(async () => { result.current.handleSsoClick(event); }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(wasSignedOut()).toBe(false); + expect(signInThroughBrowser).toHaveBeenCalledWith('oidc'); + await waitFor(() => expect(loadUser).toHaveBeenCalledWith({ silent: true })); + await waitFor(() => expect(result.current.showTakeoff).toBe(true)); + }); + + it('FE-LOGIN-NATIVE-004: sends the passkey button to the browser instead of WebAuthn', async () => { + vi.mocked(signInThroughBrowser).mockResolvedValue(); + const { result } = renderLogin(); + + await act(async () => { await result.current.handlePasskeyLogin(); }); + + expect(signInThroughBrowser).toHaveBeenCalledWith(undefined); + expect(startAuthentication).not.toHaveBeenCalled(); + }); + + it('FE-LOGIN-NATIVE-005: stays quiet when the user closes the browser', async () => { + vi.mocked(signInThroughBrowser).mockRejectedValue(new BrowserSignInCancelled()); + const { result } = renderLogin(); + + await act(async () => { await result.current.handlePasskeyLogin(); }); + + expect(result.current.error).toBe(''); + expect(result.current.isLoading).toBe(false); + }); + + it('FE-LOGIN-NATIVE-006: explains a failed hand-off', async () => { + vi.mocked(signInThroughBrowser).mockRejectedValue(new Error('exchange failed')); + const { result } = renderLogin(); + + await act(async () => { await result.current.handlePasskeyLogin(); }); + + expect(result.current.error).toBe('Signing in did not work. Please try again.'); + expect(result.current.isLoading).toBe(false); + }); + + it('FE-LOGIN-NATIVE-007: offers the way back to the address screen', () => { + const { result } = renderLogin(); + act(() => { result.current.changeServer(); }); + expect(TrekShell.resetServer).toHaveBeenCalled(); + }); +}); diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index 93238361d..2fa48ed4e 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -4,10 +4,13 @@ import { useAuthStore } from '../../store/authStore' import { useSettingsStore, hasStoredLanguage } from '../../store/settingsStore' import { useTranslation, detectBrowserLanguage } from '../../i18n' import { startAuthentication } from '@simplewebauthn/browser' -import { wasSignedOut } from '../../utils/signedOut' +import { clearSignedOut, wasSignedOut } from '../../utils/signedOut' import { authApi, configApi } from '../../api/client' import { getApiErrorMessage } from '../../types' import { START_DESTINATION_ROUTE } from '../../utils/startDestination' +import { isNativeApp } from '../../native/platform' +import { BrowserSignInCancelled, signInThroughBrowser, type BrowserSignInStart } from '../../native/browserSignIn' +import { TrekShell } from '../../native/trekShell' interface AppConfig { has_users: boolean @@ -51,6 +54,7 @@ export function useLogin() { const [inviteToken, setInviteToken] = useState('') const [inviteValid, setInviteValid] = useState(false) const exchangeInitiated = useRef(false) + const [nativeApp] = useState(isNativeApp) const [langDropdownOpen, setLangDropdownOpen] = useState(false) @@ -185,14 +189,16 @@ export function useLogin() { if (!config.has_users) setMode('register') // Skip auto-redirect when config is from cache — network is unreliable // and auto-redirecting to the IdP could loop if the proxy changed. - if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { + // Not in the app: its WebView cannot host the IdP, and opening the + // system browser uninvited on every launch would be worse than a button. + if (!fromCache && !nativeApp && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { // No switch to consult on this path: OIDC-only always asks for the // remembered lifetime, matching the SSO button on the panel (#1927). window.location.href = '/api/auth/oidc/login?remember=1' } } }) - }, [navigate, t, noRedirect]) + }, [navigate, t, noRedirect, nativeApp]) // Language detection chain (runs once on mount, only if user has no saved preference): // 1. localStorage → already in store initial state, skip @@ -233,7 +239,31 @@ export function useLogin() { } } + /** In the app: SSO and passkeys run in the system browser (native/browserSignIn.ts). */ + const handleBrowserSignIn = async (start?: BrowserSignInStart): Promise => { + setError('') + setIsLoading(true) + try { + await signInThroughBrowser(start) + await loadUser({ silent: true }) + takeOff() + } catch (err: unknown) { + if (!(err instanceof BrowserSignInCancelled)) setError(t('native.login.failed')) + setIsLoading(false) + } + } + + const handleSsoClick = (e: React.MouseEvent): void => { + clearSignedOut() + if (!nativeApp) return + e.preventDefault() + void handleBrowserSignIn('oidc') + } + + const changeServer = (): void => { void TrekShell.resetServer() } + const handlePasskeyLogin = async (): Promise => { + if (nativeApp) return handleBrowserSignIn() setError('') setIsLoading(true) try { @@ -334,5 +364,6 @@ export function useLogin() { passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword, noRedirect, showRegisterOption, oidcOnly, handleDemoLogin, handleSubmit, handlePasskeyLogin, + nativeApp, handleSsoClick, changeServer, } } diff --git a/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx b/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx new file mode 100644 index 000000000..438e6f30c --- /dev/null +++ b/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx @@ -0,0 +1,119 @@ +// FE-NATIVE-HANDOFF-001 to FE-NATIVE-HANDOFF-008 +import React from 'react' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act, waitFor } from '../../../tests/helpers/render' +import { MemoryRouter } from 'react-router' +import { resetAllStores } from '../../../tests/helpers/store' +import { useAuthStore } from '../../store/authStore' +import { nativeAuthApi } from '../../api/nativeAuth' +import { useNativeHandoff } from './useNativeHandoff' + +const mockNavigate = vi.fn() +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router') + return { ...actual, useNavigate: () => mockNavigate } +}) +vi.mock('../../api/nativeAuth', () => ({ nativeAuthApi: { handoff: vi.fn() } })) + +const challenge = 'ab'.repeat(32) +const realLocation = window.location +const logout = vi.fn(async () => {}) + +function setSearch(search: string) { + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { ...realLocation, search, href: 'http://localhost/native-handoff' + search, replace: vi.fn() }, + }) +} + +function renderHandoff() { + return renderHook(() => useNativeHandoff(), { + wrapper: ({ children }: { children: React.ReactNode }) => {children}, + }) +} + +beforeEach(() => { + resetAllStores() + mockNavigate.mockClear() + logout.mockClear() + vi.mocked(nativeAuthApi.handoff).mockReset() + sessionStorage.clear() + setSearch(`?challenge=${challenge}`) + useAuthStore.setState({ isLoading: false, isAuthenticated: true, user: { id: 1, username: 'maria' } as never, logout }) +}) + +afterEach(() => { + Object.defineProperty(window, 'location', { configurable: true, writable: true, value: realLocation }) +}) + +describe('useNativeHandoff', () => { + it('FE-NATIVE-HANDOFF-001: is ready for a signed-in user with a valid challenge', () => { + const { result } = renderHandoff() + expect(result.current.valid).toBe(true) + expect(result.current.ready).toBe(true) + expect(result.current.userName).toBe('maria') + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('FE-NATIVE-HANDOFF-002: rejects a malformed challenge and does nothing with it', () => { + setSearch('?challenge=nope') + useAuthStore.setState({ isAuthenticated: false }) + const { result } = renderHandoff() + expect(result.current.valid).toBe(false) + expect(result.current.ready).toBe(false) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('FE-NATIVE-HANDOFF-003: sends a visitor to the login page and back here afterwards', () => { + useAuthStore.setState({ isAuthenticated: false }) + renderHandoff() + expect(mockNavigate).toHaveBeenCalledWith( + `/login?redirect=${encodeURIComponent(`/native-handoff?challenge=${challenge}`)}`, + { replace: true }, + ) + }) + + it('FE-NATIVE-HANDOFF-004: starts SSO straight away when the app asked for it, without looping', () => { + setSearch(`?challenge=${challenge}&start=oidc`) + useAuthStore.setState({ isAuthenticated: false }) + renderHandoff() + expect(window.location.replace).toHaveBeenCalledWith('/api/auth/oidc/login?remember=1') + expect(sessionStorage.getItem('oidc_redirect')).toBe(`/native-handoff?challenge=${challenge}`) + }) + + it('FE-NATIVE-HANDOFF-005: waits while the session is still being checked', () => { + useAuthStore.setState({ isAuthenticated: false, isLoading: true }) + const { result } = renderHandoff() + expect(result.current.ready).toBe(false) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('FE-NATIVE-HANDOFF-006: only mints the code on the tap, then hands it to the app scheme', async () => { + vi.mocked(nativeAuthApi.handoff).mockResolvedValue({ code: 'c/1' }) + const { result } = renderHandoff() + expect(nativeAuthApi.handoff).not.toHaveBeenCalled() + + await act(async () => { await result.current.openApp() }) + + expect(nativeAuthApi.handoff).toHaveBeenCalledWith(challenge) + expect(window.location.href).toBe('com.liketrek.trek://auth?code=c%2F1') + }) + + it('FE-NATIVE-HANDOFF-007: reports a failed hand-off', async () => { + vi.mocked(nativeAuthApi.handoff).mockRejectedValue(new Error('401')) + const { result } = renderHandoff() + await act(async () => { await result.current.openApp() }) + expect(result.current.status).toBe('failed') + }) + + it('FE-NATIVE-HANDOFF-008: lets the user switch accounts and come back', async () => { + const { result } = renderHandoff() + await act(async () => { await result.current.switchAccount() }) + expect(logout).toHaveBeenCalled() + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith( + `/login?redirect=${encodeURIComponent(`/native-handoff?challenge=${challenge}`)}`, + { replace: true }, + )) + }) +}) diff --git a/client/src/pages/nativeHandoff/useNativeHandoff.ts b/client/src/pages/nativeHandoff/useNativeHandoff.ts new file mode 100644 index 000000000..01b4b52f4 --- /dev/null +++ b/client/src/pages/nativeHandoff/useNativeHandoff.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' +import { useShallow } from 'zustand/react/shallow' +import { useAuthStore } from '../../store/authStore' +import { nativeAuthApi } from '../../api/nativeAuth' +import { APP_URL_SCHEME } from '../../native/trekShell' +import { HANDOFF_PATH } from '../../native/browserSignIn' + +const CHALLENGE = /^[0-9a-f]{64}$/ + +/** + * The system-browser end of the app's sign-in (see native/browserSignIn.ts). + * Signs the user in here if needed, then hands the session to the app only + * after an explicit tap: a link to this page with somebody else's challenge + * must not be able to pass a session on silently. + */ +export function useNativeHandoff() { + const navigate = useNavigate() + const { isAuthenticated, isLoading, userName, logout } = useAuthStore(useShallow((s) => ({ + isAuthenticated: s.isAuthenticated, + isLoading: s.isLoading, + userName: s.user?.username ?? '', + logout: s.logout, + }))) + const [params] = useState(() => new URLSearchParams(window.location.search)) + const challenge = params.get('challenge') ?? '' + const valid = CHALLENGE.test(challenge) + const [status, setStatus] = useState<'idle' | 'sending' | 'failed'>('idle') + + // Back here once signed in, without `start`, so an SSO round trip cannot loop. + const returnPath = `${HANDOFF_PATH}?challenge=${challenge}` + + useEffect(() => { + if (!valid || isLoading || isAuthenticated) return + if (params.get('start') === 'oidc') { + sessionStorage.setItem('oidc_redirect', returnPath) + window.location.replace('/api/auth/oidc/login?remember=1') + return + } + navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true }) + }, [valid, isLoading, isAuthenticated, params, returnPath, navigate]) + + const openApp = async (): Promise => { + setStatus('sending') + try { + const { code } = await nativeAuthApi.handoff(challenge) + window.location.href = `${APP_URL_SCHEME}://auth?code=${encodeURIComponent(code)}` + } catch { + setStatus('failed') + } + } + + const switchAccount = async (): Promise => { + await logout() + navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true }) + } + + return { + valid, + ready: valid && !isLoading && isAuthenticated, + userName, + status, + openApp, + switchAccount, + } +} diff --git a/client/src/utils/fileDownload.ts b/client/src/utils/fileDownload.ts index 96ae0cbb6..1957545e9 100644 --- a/client/src/utils/fileDownload.ts +++ b/client/src/utils/fileDownload.ts @@ -1,5 +1,7 @@ import { getCachedBlob } from '../db/offlineDb' import { isEffectivelyOffline } from '../sync/networkMode' +import { isNativeApp } from '../native/platform' +import { fileNameFromUrl, shareFile } from '../native/nativeFiles' // MIME types safe to open inline (will not execute script in any browser). // Everything else (text/html, image/svg+xml, text/javascript, …) is forced to @@ -80,6 +82,10 @@ async function getFileBlob(url: string): Promise { * in the same tick can abort the download in other browsers. */ export function downloadBlob(blob: Blob, filename: string): void { + if (isNativeApp()) { + shareFile(blob, filename).catch((err) => console.error('[download]', err)) + return + } const blobUrl = URL.createObjectURL(blob) triggerAnchorDownload(blobUrl, filename) } @@ -93,6 +99,7 @@ export function downloadBlob(blob: Blob, filename: string): void { */ export async function downloadFile(url: string, filename?: string): Promise { const blob = await getFileBlob(url) + if (isNativeApp()) return shareFile(blob, filename ?? fileNameFromUrl(url)) const blobUrl = URL.createObjectURL(blob) triggerAnchorDownload(blobUrl, filename) } @@ -116,6 +123,9 @@ export async function downloadFile(url: string, filename?: string): Promise { const blob = await getFileBlob(url) + // The share sheet previews PDFs and images too, and a blob tab has nowhere + // to open inside the app. + if (isNativeApp()) return shareFile(blob, filename ?? fileNameFromUrl(url)) const blobUrl = URL.createObjectURL(blob) // Force download for MIME types that can execute script when rendered inline diff --git a/client/tests/unit/utils/fileDownload.native.test.ts b/client/tests/unit/utils/fileDownload.native.test.ts new file mode 100644 index 000000000..cec531875 --- /dev/null +++ b/client/tests/unit/utils/fileDownload.native.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { downloadBlob, downloadFile, openFile } from '../../../src/utils/fileDownload' +import { shareFile } from '../../../src/native/nativeFiles' + +vi.mock('../../../src/db/offlineDb', () => ({ getCachedBlob: vi.fn() })) +vi.mock('../../../src/native/platform', () => ({ isNativeApp: () => true })) +vi.mock('../../../src/native/nativeFiles', async () => { + const actual = await vi.importActual('../../../src/native/nativeFiles') + return { ...actual, shareFile: vi.fn() } +}) + +const pdf = new Blob(['%PDF'], { type: 'application/pdf' }) + +beforeEach(() => { + vi.mocked(shareFile).mockReset().mockResolvedValue() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, blob: () => Promise.resolve(pdf) })) +}) + +describe('fileDownload inside the native app', () => { + it('hands an in-memory blob to the share sheet instead of an anchor', () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click') + downloadBlob(pdf, 'report.pdf') + expect(shareFile).toHaveBeenCalledWith(pdf, 'report.pdf') + expect(click).not.toHaveBeenCalled() + }) + + it('logs a share that failed rather than losing it', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(shareFile).mockRejectedValue(new Error('no space')) + downloadBlob(pdf, 'report.pdf') + await vi.waitFor(() => expect(error).toHaveBeenCalledWith('[download]', expect.any(Error))) + }) + + it('shares a protected file under its given name, or the one from its URL', async () => { + await downloadFile('/api/trips/1/files/2/download', 'Boarding pass.pdf') + expect(shareFile).toHaveBeenLastCalledWith(pdf, 'Boarding pass.pdf') + + await downloadFile('/uploads/files/ticket%20A.pdf') + expect(shareFile).toHaveBeenLastCalledWith(pdf, 'ticket A.pdf') + }) + + it('opens files through the share sheet too, since there is no tab to open them in', async () => { + await openFile('/api/trips/1/files/2/download') + expect(shareFile).toHaveBeenLastCalledWith(pdf, 'download') + }) +}) diff --git a/client/vite.config.js b/client/vite.config.js index 0be595f4d..0cb17d930 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -265,6 +265,10 @@ export default defineConfig(({ mode }) => ({ // is, so a toolchain bump can't silently change which browsers still work. target: 'es2022', sourcemap: false, + // Served as /asset-manifest.json. The iOS app, which runs no service worker, + // walks it to pull every chunk into WebKit's cache so TREK can start offline + // (src/native/offlineShell.ts). + manifest: 'asset-manifest.json', modulePreload: { polyfill: true }, // Vite 8 bundles with rolldown, not rollup. `rollupOptions` is only an alias // onto `rolldownOptions`, and both `manualChunks` and `advancedChunks` are diff --git a/package-lock.json b/package-lock.json index 95c86a4d5..4deaa01cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@fontsource/poppins": "^5.2.7", "@mapbox/mapbox-gl-rtl-text": "^0.4.0", "@maplibre/maplibre-gl-leaflet": "0.1.4", + "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@trek/shared": "*", "axios": "^1.6.7", @@ -6440,7 +6441,6 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "devOptional": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16"