mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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>} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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'] })
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user