feat(native): the UI knows when it runs inside the app

Everything the server's UI does differently inside the iOS and Android app
lives under src/native and is inert in a browser.

Sign-in: SSO and the passkey button go through the system browser
(signInThroughBrowser), because identity providers refuse embedded
browsers and WebAuthn needs a domain association no build can have for
every server. The browser end is the new /native-handoff page: it signs
the user in if needed and hands the session over only after an explicit
tap, so a link carrying somebody else's challenge cannot pass a session
on silently. The verifier is hashed with @noble/hashes because
crypto.subtle is missing on the plain-http servers many home networks
use. An OIDC-only install no longer bounces to the IdP inside the
WebView, and passkey management is hidden in the app.

Downloads: the WebView cannot save a blob, so downloadBlob, downloadFile
and openFile write to the app cache and open the share sheet. The eight
places that built their own download anchor now use downloadBlob, which
they should have anyway.

The rest: "Change server" on the login page and in the account settings,
the splash hides after the first frame, Android's back button walks
history before it leaves the app, the status bar follows the scheme, and
on iOS the start page is refreshed and every chunk of the build (listed
in the new asset-manifest.json) is pulled into WebKit's cache so the app
can start without network.
This commit is contained in:
Maurice
2026-09-24 12:42:34 +02:00
parent c5765bbf15
commit ae0d4e4520
37 changed files with 1153 additions and 88 deletions
+1
View File
@@ -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",
+2
View File
@@ -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() {
<Route path="/public/journey/:token" element={<PublicRoute><JourneyPublicPage /></PublicRoute>} />
<Route path="/register" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
<Route path="/forgot-password" element={<PublicRoute><ForgotPasswordPage /></PublicRoute>} />
<Route path="/native-handoff" element={<PublicRoute><NativeHandoffPage /></PublicRoute>} />
<Route path="/reset-password" element={<PublicRoute><ResetPasswordPage /></PublicRoute>} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/consent" element={<PublicRoute><OAuthAuthorizePage /></PublicRoute>} />
+12
View File
@@ -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<NativeHandoffResponse> =>
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<void> =>
apiClient.post('/auth/native/exchange', body).then(() => undefined),
}
@@ -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<boolean>(() => 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)' }
@@ -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 () => {
@@ -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. */
@@ -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'))
@@ -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();
+9 -11
View File
@@ -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 (
<>
<NativeServerSection />
<Section title={t('settings.account')} icon={User}>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
@@ -405,8 +401,10 @@ export default function AccountTab(): React.ReactElement {
</div>
</div>
{/* Passkeys */}
<PasskeysSection demoMode={demoMode} />
{/* 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() && <PasskeysSection demoMode={demoMode} />}
{/* Avatar */}
<div className="flex items-center gap-4">
@@ -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(<Section />)
expect(container).toBeEmptyDOMElement()
})
it('names the server and leads back to the address screen', () => {
vi.mocked(nativeServerHost).mockReturnValue('trek.example.com')
render(<Section />)
expect(screen.getByText('This app is connected to trek.example.com.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Change server' }))
expect(TrekShell.resetServer).toHaveBeenCalled()
})
})
@@ -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 (
<Section title={t('native.settings.server')} icon={Server}>
<p className="text-body text-content-secondary">{t('native.settings.serverHint', { server: host })}</p>
<button
type="button"
onClick={() => { void TrekShell.resetServer() }}
className="rounded-lg border border-edge bg-surface-card px-4 py-2 text-body font-medium text-content hover:bg-surface-hover"
>
{t('native.changeServer')}
</button>
</Section>
)
}
+2 -10
View File
@@ -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 {
+3
View File
@@ -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(
<React.StrictMode>
@@ -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 && <MSettingsPlugins />}
{active.id === 'offline' && <MSettingsOffline />}
{active.id === 'account' && <MSettingsServer />}
{active.id === 'account' && <MSettingsAccount />}
{active.id === 'about' && appVersion && <MSettingsAbout appVersion={appVersion} />}
@@ -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() {
)}
</MSetCard>
<MPasskeysCard demoMode={demoMode} />
{/* Not in the app: see the passkeys note in components/Settings/AccountTab.tsx. */}
{!isNativeApp() && <MPasskeysCard demoMode={demoMode} />}
<MConfirmSheet
open={showDeleteConfirm === 'blocked'}
@@ -0,0 +1,18 @@
import { Server } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { nativeServerHost } from '../../../native/platform'
import { TrekShell } from '../../../native/trekShell'
import { MSetButton, MSetCard, MSetHint } from './MSettingsUi'
/** Only inside the iOS/Android app: which server it talks to, and the way to another one. */
export default function MSettingsServer() {
const { t } = useTranslation()
const host = nativeServerHost()
if (!host) return null
return (
<MSetCard title={t('native.settings.server')} icon={Server} className="mb-3">
<MSetHint className="mt-0 mb-3">{t('native.settings.serverHint', { server: host })}</MSetHint>
<MSetButton variant="ghost" onClick={() => { void TrekShell.resetServer() }}>{t('native.changeServer')}</MSetButton>
</MSetCard>
)
}
@@ -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'))
+63
View File
@@ -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()
})
})
+61
View File
@@ -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<void> {
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 })
}
+96
View File
@@ -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())
})
})
+49
View File
@@ -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 <html>, 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'] })
}
+49
View File
@@ -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"<e>|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')
})
})
+46
View File
@@ -0,0 +1,46 @@
import { Directory, Filesystem } from '@capacitor/filesystem'
import { Share } from '@capacitor/share'
function toBase64(blob: Blob): Promise<string> {
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<void> {
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
}
}
+66
View File
@@ -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)
})
})
+51
View File
@@ -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<string>()
for (const chunk of Object.values(manifest as Record<string, ManifestChunk>)) {
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<unknown>): Promise<void> {
const queue = [...files]
const worker = async (): Promise<void> => {
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<void> {
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()))
}
+15
View File
@@ -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
}
+25 -3
View File
@@ -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 {
</div>
)}
<a
onClick={clearSignedOut}
onClick={handleSsoClick}
// No remember-me switch in OIDC-only mode: the IdP owns the session
// policy, so always ask for the remembered lifetime (#1927).
href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) + '&' : '?'}remember=1`}
@@ -1101,7 +1103,7 @@ export default function LoginPage(): React.ReactElement {
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
</div>
<a
onClick={clearSignedOut}
onClick={handleSsoClick}
href={`/api/auth/oidc/login${
inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''
}${
@@ -1238,6 +1240,26 @@ export default function LoginPage(): React.ReactElement {
{t('login.demoHint')}
</button>
)}
{nativeApp && (
<button
type="button"
onClick={changeServer}
style={{
marginTop: 16,
width: '100%',
padding: '8px',
background: 'none',
border: 'none',
color: '#9ca3af',
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontFamily: 'inherit',
cursor: 'pointer',
}}
>
{t('native.changeServer')}
</button>
)}
</div>
</div>
@@ -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(<NativeHandoffPage />)
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(<NativeHandoffPage />)
expect(screen.getByRole('alert')).toHaveTextContent('Signing in did not work. Please try again.')
vi.mocked(useNativeHandoff).mockReturnValue({ ...base, status: 'sending' })
rerender(<NativeHandoffPage />)
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(<NativeHandoffPage />)
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(<NativeHandoffPage />)
expect(screen.getByText('Signing in did not work. Please try again.')).toBeInTheDocument()
})
})
+38
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-surface-secondary p-6">
<div className="w-full max-w-sm rounded-xl bg-surface-card border border-edge p-7 flex flex-col gap-4 text-center">
<Smartphone className="mx-auto text-content-muted" size={32} aria-hidden />
{!valid ? (
<p className="text-body text-content">{t('native.login.failed')}</p>
) : !ready ? (
<div className="mx-auto w-8 h-8 border-4 border-edge border-t-accent rounded-full animate-spin" aria-label={t('common.loading')} />
) : (
<>
<h1 className="text-title font-semibold text-content">{t('native.handoff.title')}</h1>
<p className="text-body text-content-muted">{t('native.handoff.hint', { name: userName })}</p>
{status === 'failed' && <p role="alert" className="text-body text-danger">{t('native.login.failed')}</p>}
<button
type="button"
onClick={openApp}
disabled={status === 'sending'}
className="w-full rounded-lg bg-accent text-accent-on py-3 font-semibold disabled:opacity-60"
>
{t('native.handoff.open')}
</button>
<button type="button" onClick={switchAccount} className="text-caption text-content-muted py-1">
{t('native.handoff.switchAccount')}
</button>
</>
)}
</div>
</div>
)
}
@@ -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<typeof import('react-router')>('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<typeof import('../../native/browserSignIn')>('../../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 }) => (
<MemoryRouter initialEntries={['/login']}>
<TranslationProvider>{children}</TranslationProvider>
</MemoryRouter>
),
});
}
function clickEvent() {
return { preventDefault: vi.fn() } as unknown as React.MouseEvent<HTMLAnchorElement>;
}
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();
});
});
+34 -3
View File
@@ -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<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const exchangeInitiated = useRef(false)
const [nativeApp] = useState(isNativeApp)
const [langDropdownOpen, setLangDropdownOpen] = useState<boolean>(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<void> => {
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<HTMLAnchorElement>): void => {
clearSignedOut()
if (!nativeApp) return
e.preventDefault()
void handleBrowserSignIn('oidc')
}
const changeServer = (): void => { void TrekShell.resetServer() }
const handlePasskeyLogin = async (): Promise<void> => {
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,
}
}
@@ -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<typeof import('react-router')>('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 }) => <MemoryRouter>{children}</MemoryRouter>,
})
}
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 },
))
})
})
@@ -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<void> => {
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<void> => {
await logout()
navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true })
}
return {
valid,
ready: valid && !isLoading && isAuthenticated,
userName,
status,
openApp,
switchAccount,
}
}
+10
View File
@@ -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<Blob> {
* 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<void> {
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<void
*/
export async function openFile(url: string, filename?: string): Promise<void> {
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
@@ -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<typeof import('../../../src/native/nativeFiles')>('../../../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')
})
})
+4
View File
@@ -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
+1 -1
View File
@@ -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"