Files
TREK/client/src/pages/nativeHandoff/useNativeHandoff.ts
T
Maurice 034cfb539a fix(native): the app's browser sign-in no longer lands on the dashboard when the browser still holds an older TREK
A browser that ran TREK before 5.1.1 keeps that version's app in its service
worker. When the app opened /native-handoff, the worker answered it from its
cache with an app that has no such page, which sent the user to /, and the
sign-in ended on the dashboard in the browser instead of in the app.

The app now opens /api/auth/native/start, which every TREK service worker
passes to the server. That page takes the workers off the browser and
forwards to the handoff page. Until the sign-in is handed back, the web app
registers no service worker and does not reload for a new version: a tab
still open on the older TREK keeps its worker alive, and a registration or a
reload in the middle of the sign-in brought it back to answer the next page.
2026-11-09 23:02:33 +01:00

100 lines
3.6 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useShallow } from 'zustand/react/shallow';
import { HANDOFF_PATH, rememberHandoffReturn, requestHandoffCode, signInWithPasskey } from '../../native/browserSignIn';
import { endBrowserSignIn } from '../../native/browserSignInFlag';
import { APP_URL_SCHEME } from '../../native/trekShell';
import { useAuthStore } from '../../store/authStore';
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, loadUser } = useAuthStore(
useShallow((s) => ({
isAuthenticated: s.isAuthenticated,
isLoading: s.isLoading,
userName: s.user?.username ?? '',
logout: s.logout,
loadUser: s.loadUser,
}))
);
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');
// The app asked for a passkey: this page asks for it itself instead of showing
// the whole login a second time, until the user picks another way to sign in.
const [passkeyStep, setPasskeyStep] = useState(() => params.get('start') === 'passkey');
// 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 || passkeyStep) return;
if (params.get('start') === 'oidc') {
sessionStorage.setItem('oidc_redirect', returnPath);
rememberHandoffReturn(returnPath);
window.location.replace('/api/auth/oidc/login?remember=1');
return;
}
void navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true });
}, [valid, isLoading, isAuthenticated, passkeyStep, params, returnPath, navigate]);
const openApp = async (): Promise<void> => {
setStatus('sending');
try {
const code = await requestHandoffCode(challenge);
endBrowserSignIn();
window.location.href = `${APP_URL_SCHEME}://auth?code=${encodeURIComponent(code)}`;
} catch {
setStatus('failed');
}
};
/**
* The passkey prompt, then straight back to the app: the tap that started it
* is the explicit consent the handoff asks for, so no second page follows.
*/
const continueWithPasskey = async (): Promise<void> => {
setStatus('sending');
try {
await signInWithPasskey();
await loadUser({ silent: true });
} catch (err: unknown) {
const name = (err as { name?: string })?.name;
setStatus(name === 'NotAllowedError' || name === 'AbortError' ? 'idle' : 'failed');
return;
}
await openApp();
};
const otherSignIn = (): void => {
setStatus('idle');
setPasskeyStep(false);
};
const switchAccount = async (): Promise<void> => {
await logout();
void navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true });
};
return {
valid,
passkeyStep: valid && !isLoading && !isAuthenticated && passkeyStep,
continueWithPasskey,
otherSignIn,
ready: valid && !isLoading && isAuthenticated,
userName,
status,
openApp,
switchAccount,
};
}