'use client' import { useState, useEffect, useRef, useCallback } from 'react' import Image from 'next/image' import { useTranslations } from 'next-intl' import { BankIdQrCode } from './BankIdQrCode' import { Button } from '@/components/ui/button' import { Smartphone, Monitor, AlertTriangle, Loader2 } from 'lucide-react' type BankIdStatus = | 'idle' | 'scanning' | 'complete' | 'failed' | 'no_account' | 'service_unavailable' /** A flow exists in this browser that this browsing context did not start. */ | 'resumable' /** Avbryt pressed: waiting out /cancel before a new flow may be started. */ | 'cancelling' /** Max consecutive poll failures before we declare service unavailable */ const MAX_POLL_FAILURES = 3 /** * Abandon a session that never reaches a terminal state. Safety net for TIC * responses that carry no `status` (e.g. an expired-session 410 body): without * it the poll loop would spin forever on a dead QR code. */ const POLL_DEADLINE_MS = 6 * 60 * 1000 /** Min spacing between billable TIC session starts (mirrors the server cooldown). */ const START_COOLDOWN_MS = 5_000 /** * What the client is allowed to know about a BankID order. Note the absence * of a session id: it is a bearer credential for the holder's personnummer * and for a Supabase session, so it stays in a signed HttpOnly cookie the * server sets at /start (extensions/general/tic/lib/bankid-flow-cookie.ts). * The tokens below are the ones the BankID app and the QR code need, and * neither identifies anybody. */ interface BankIdSession { flowId: string autoStartToken: string qrStartToken: string qrStartSecret: string } export interface BankIdResult { tokenHash?: string type?: string isNewUser?: boolean error?: 'no_account' | 'already_linked' | 'session_invalid' | 'service_unavailable' | 'email_unconfirmed' /** Server-provided Swedish explanation for errors that carry one (e.g. email_unconfirmed). */ message?: string givenName?: string surname?: string /** Non-secret id that binds this tab to the shared server-held flow. */ flowId?: string } interface BankIdAuthProps { mode: 'login' | 'signup' | 'link' onComplete: (result: BankIdResult) => void /** * Render the idle button as the panel's primary action (filled pill) instead * of the default outline. Used by the login page, where BankID owns the * primary zone; register and settings keep the quieter outline. */ hero?: boolean } const API_BASE = '/api/extensions/ext/tic/bankid' const FLOW_ID_HEADER = 'x-bankid-flow-id' function isMobile(): boolean { if (typeof navigator === 'undefined') return false return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) } /** * Launch the BankID app on the same (mobile) device. * * Uses the universal link https://app.bankid.com/: NOT the bankid:/// custom * scheme. A custom-scheme launch has no association with the originating Safari * tab, so on iOS the post-auth redirect opens in a NEW tab (git history: commit * 3bc652cc reverted a redirect for exactly that reason). * * redirect: * • iOS → current URL, so the app navigates back here on success. In * Safari that is this very tab; in a third-party browser or an * in-app web view the OS hands the URL to the default browser * and it lands in a NEW tab. Both work: the flow lives in a * cookie, which every tab of the origin shares, so whichever tab * the user ends up in simply polls and carries on. * • Android → "null": the BankID app returns via the task stack, and a real * redirect URL would spawn a new tab / Chrome instance instead. */ function launchBankIdApp(autoStartToken: string): void { const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '' const isIOS = /iPad|iPhone|iPod/.test(ua) const redirect = isIOS ? encodeURIComponent(window.location.href) : 'null' window.location.href = `https://app.bankid.com/?autostarttoken=${autoStartToken}&redirect=${redirect}` } /** * BankID authentication flow component. * Handles QR code display (desktop) or app deep link (mobile), * polling, and result handling. */ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps) { const t = useTranslations('auth') const [status, setStatus] = useState('idle') const [session, setSession] = useState(null) const [activeFlowId, setActiveFlowId] = useState(null) const [hintMessage, setHintMessage] = useState('') const [errorMessage, setErrorMessage] = useState('') const [launchedApp, setLaunchedApp] = useState(false) const pollRef = useRef | null>(null) const abortRef = useRef(null) const lastStartRef = useRef(0) const onCompleteRef = useRef(onComplete) onCompleteRef.current = onComplete const pollFailureCount = useRef(0) /** True while a poll response is being processed: prevents overlapping ticks. */ const pollInFlightRef = useRef(false) /** Set once a terminal poll result has been handled: the completion branch must run at most once. */ const completedRef = useRef(false) /** When the current poll loop began, for the POLL_DEADLINE_MS cap. */ const pollStartedAtRef = useRef(0) /** Bumped by cancel/unmount so an in-flight startSession stops touching state. */ const startGenRef = useRef(0) /** True while startSession is running: collapses double-clicks into one billable session. */ const startingRef = useRef(false) /** * True when this tab is polling a flow it did not start, on the strength of * the hint cookie alone. Decides how a 404 reads: nothing to resume (quietly * show the button) versus this tab's own order expiring (say so). */ const resumedRef = useRef(false) const cleanup = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current) pollRef.current = null } if (abortRef.current) { abortRef.current.abort() abortRef.current = null } pollFailureCount.current = 0 pollInFlightRef.current = false }, []) useEffect( () => () => { startGenRef.current++ cleanup() }, [cleanup] ) // Poll the flow this browser holds until it completes, fails, or the service // gives up. Extracted from startSession so the resume effect (the tab BankID // returned the user to) can attach to a flow it did not start. const beginPolling = useCallback((flowId: string) => { // Never leave a previous interval running: two loops would share // pollFailureCount and completedRef, and the orphan would eventually // declare a healthy session unavailable and clear the live one. cleanup() abortRef.current = new AbortController() pollStartedAtRef.current = Date.now() completedRef.current = false pollInFlightRef.current = false pollRef.current = setInterval(async () => { // Never process two ticks concurrently: a slow response overlapping the // next tick could otherwise run the completion branch twice (double // /complete → double generateLink, which invalidates the first magic // link and fails the login intermittently). if (pollInFlightRef.current || completedRef.current) return // Hard cap: a session that never reaches a terminal state (expired // order, TIC response without `status`) must not poll forever. if (Date.now() - pollStartedAtRef.current > POLL_DEADLINE_MS) { cleanup() setStatus('failed') setErrorMessage('BankID-sessionen löpte ut. Försök igen.') return } pollInFlightRef.current = true try { // The server reads the secret session from the cookie. The body and // non-secret header bind this polling loop to its mode and flow id. const pollRes = await fetch(`${API_BASE}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json', [FLOW_ID_HEADER]: flowId, }, body: JSON.stringify({ mode }), signal: abortRef.current?.signal, }) // The server says this browser holds no live flow: it finished in // another tab, was cancelled, or expired. Terminal, and NOT a service // failure, so settle instead of spinning until the deadline. // // Matched on the body code, not the bare status: 404 is also what the // extension dispatcher returns for an unknown route and what the CDN // returns mid-deploy, and treating those as "your session died" would // abandon a live order and cost a second billable one. if (pollRes.status === 404) { const reason = await pollRes.json().catch(() => null) if (reason?.error !== 'no_session') { pollFailureCount.current++ return } completedRef.current = true cleanup() if (resumedRef.current) { // We only probed on the chance a flow existed. It does not, so // quietly offer the start button. setStatus('idle') } else { // This tab watched its own order die. Say so: silently reverting to // the start button reads as "it just did nothing". setStatus('failed') setErrorMessage('BankID-sessionen löpte ut. Försök igen.') } setSession(null) setActiveFlowId(null) return } if (!pollRes.ok) { // Count EVERY failed poll (5xx, 429, unexpected 4xx): errors that // never increment the counter would otherwise leave the user // silently polling a dead session forever. pollFailureCount.current++ if (pollFailureCount.current >= MAX_POLL_FAILURES) { cleanup() setStatus('service_unavailable') setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') onCompleteRef.current({ error: 'service_unavailable' }) } return } // Reset failure counter on successful poll pollFailureCount.current = 0 const pollJson = await pollRes.json() const pollData = pollJson.data if (!pollData) { console.warn('[bankid] poll returned no data:', pollJson) return } // Update hint message from TIC API if (pollData.message) { setHintMessage(pollData.message) } // Token refresh (the order regenerates roughly every 25s). Adopted // even when this tab has no session of its own: that is how a resumed // tab (a reload, or the tab BankID returned the user to) gets a // renderable QR code instead of a spinner with nothing under it. if (pollData.qrStartToken && pollData.qrStartSecret) { setSession((prev) => ({ flowId, autoStartToken: prev?.autoStartToken ?? '', qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret, })) } if (pollData.status === 'complete') { completedRef.current = true cleanup() setStatus('complete') if (mode === 'login') { // For login, call /complete to exchange for Supabase session. // Session and mode come from the flow cookie; the server clears it // as it answers, so this is single-use and a second tab that also // saw 'complete' gets session_invalid rather than minting a rival // magic link that would invalidate this one. try { const completeRes = await fetch(`${API_BASE}/complete`, { method: 'POST', headers: { [FLOW_ID_HEADER]: flowId }, }) const completeJson = await completeRes.json() if (!completeRes.ok) { const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503 ? 'service_unavailable' as const : completeJson.error if (errorCode === 'service_unavailable') { setStatus('service_unavailable') setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') } onCompleteRef.current({ error: errorCode, message: typeof completeJson.message === 'string' ? completeJson.message : undefined, givenName: completeJson.givenName, surname: completeJson.surname, }) return } onCompleteRef.current({ tokenHash: completeJson.data.tokenHash, type: completeJson.data.type, isNewUser: completeJson.data.isNewUser, }) } catch { onCompleteRef.current({ error: 'session_invalid' }) } } else if (mode === 'link') { // For link, call /link to associate BankID with current user. The // server requires the flow cookie to have been opened in 'link' // mode, so a session started to sign someone in cannot be turned // into a binding against whoever is logged in here. try { const linkRes = await fetch(`${API_BASE}/link`, { method: 'POST', headers: { [FLOW_ID_HEADER]: flowId }, }) const linkJson = await linkRes.json() if (!linkRes.ok) { onCompleteRef.current({ error: linkJson.error }) return } onCompleteRef.current({}) } catch { onCompleteRef.current({ error: 'session_invalid' }) } } else { // For signup, hand the parent the verified name so it can collect // an e-mail. The flow itself stays in the cookie, so the parent // posts only that e-mail to /complete. onCompleteRef.current({ givenName: pollData.user?.givenName, surname: pollData.user?.surname, flowId, }) } } else if (pollData.status === 'failed' || pollData.status === 'cancelled') { completedRef.current = true cleanup() setStatus('failed') setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades') } } catch (error) { if (error instanceof Error && error.name === 'AbortError') return pollFailureCount.current++ if (pollFailureCount.current >= MAX_POLL_FAILURES) { cleanup() setStatus('service_unavailable') setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') onCompleteRef.current({ error: 'service_unavailable' }) } } finally { pollInFlightRef.current = false } }, 2000) }, [cleanup, mode]) const startSession = useCallback(async () => { // Collapse double-clicks: one billable TIC session per intent. if (startingRef.current) return startingRef.current = true const gen = ++startGenRef.current cleanup() resumedRef.current = false setActiveFlowId(null) setLaunchedApp(false) setStatus('scanning') setHintMessage('Starta BankID-appen') setErrorMessage('') try { // Respect the billable-session cooldown by waiting out the remainder // instead of silently dropping the click: a retry button that does // nothing reads as "BankID is broken". Also keeps us under the // server-side per-IP cooldown on /start. const sinceLast = Date.now() - lastStartRef.current if (sinceLast < START_COOLDOWN_MS) { await new Promise((resolve) => setTimeout(resolve, START_COOLDOWN_MS - sinceLast)) } if (gen !== startGenRef.current) return // cancelled/unmounted while waiting lastStartRef.current = Date.now() // The mode is pinned server-side into the flow cookie, so the session // this opens can only ever be finished as this kind of flow. const res = await fetch(`${API_BASE}/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }), }) if (gen !== startGenRef.current) return if (!res.ok) { const err = await res.json().catch(() => ({})) if (err.error === 'service_unavailable' || err.error === 'not_configured' || res.status === 502 || res.status === 503) { cleanup() setStatus('service_unavailable') setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') onCompleteRef.current({ error: 'service_unavailable' }) return } if (res.status === 429 || err.error === 'rate_limit') { setStatus('failed') setErrorMessage('För många försök. Vänta en stund och försök igen.') return } // Unknown error: server messages are not user-facing copy; keep the // detail in the console and show Swedish. console.error('[bankid] start failed', res.status, err) setStatus('failed') setErrorMessage('Ett oväntat fel uppstod. Försök igen.') return } const { data } = await res.json() const newSession: BankIdSession = data setSession(newSession) setActiveFlowId(newSession.flowId) // On mobile, open the BankID app on this device. Nothing needs saving // first: /start already set the flow cookie, and a cookie is shared by // every tab of the origin, so whichever tab BankID returns the user to // (this one reloaded, or a brand new one) can resume from it. if (isMobile()) { setLaunchedApp(true) launchBankIdApp(newSession.autoStartToken) } beginPolling(newSession.flowId) } catch (error) { if (gen !== startGenRef.current) return console.error('[bankid] start failed', error) setStatus('failed') setErrorMessage('Ett oväntat fel uppstod. Försök igen.') } finally { startingRef.current = false } }, [cleanup, mode, beginPolling]) /** * Start (or continue) polling the flow this browser holds. Called from the * "Fortsätt" confirm button and from startSession, never automatically on * mount: see the probe effect below for why. */ const resumePolling = useCallback(() => { if (!activeFlowId) return resumedRef.current = true setStatus('scanning') setHintMessage(t('bankid_resume_hint')) beginPolling(activeFlowId) }, [activeFlowId, beginPolling, t]) /** * On mount, ask ONCE whether this browser is mid-flow, and if so present a * confirm card rather than continuing automatically. * * This is the fix for the reported bug and the safe shape for it. BankID does * not reliably return the user to the tab that started the flow: on iOS * outside plain Safari the redirect opens a NEW tab, and even the same tab is * reloaded. The flow lives in a shared cookie now, so any of those tabs can * pick it up. But a shared cookie plus a shared machine means the tab that * finds a completed identification cannot prove the person sitting at it is * the one who made it: nothing the client can store survives an iOS reload * yet dies on "reopen closed tab" / session restore / tab duplication, so a * completed flow could otherwise be auto-consumed by the next person to open * the page and sign them into, or bind their account to, a stranger. * * So a resume is never automatic: a found flow routes to the confirm card * ("Fortsätt bara om det var du"), and only that click consumes it. The cost * is one tap after returning from the BankID app on iOS, which is where the * reported bug lives; desktop QR (no reload) and Android (returns to the live * tab) never hit this path and are unchanged. `link` never resumes at all. */ useEffect(() => { if (mode === 'link') return let cancelled = false const gen = startGenRef.current void (async () => { try { const res = await fetch(`${API_BASE}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode, probe: true }), }) // No live flow this browser can point to (no cookie, or one for a // different mode): leave the start button. if (cancelled || res.status === 404 || !res.ok) return const json = await res.json().catch(() => null) const status = json?.data?.status if (!status || status === 'failed' || status === 'cancelled') return // A start begun while the probe was in flight owns the panel; do not // override it with a confirm card for the flow it is replacing. if (cancelled || gen !== startGenRef.current || startingRef.current) return // A live flow exists. It may be this person returning from the BankID // app, or a stranger's identification left on a shared machine; the two // are indistinguishable from here, so it is theirs to confirm, never // ours to spend. The probe deliberately gets no holder name from /poll, // so the card cannot reveal whose identification it is. const flowId = json?.data?.flowId if (typeof flowId !== 'string' || !flowId) return setActiveFlowId(flowId) setStatus('resumable') } catch { // Offline or a blip: leave the start button. A flow that really is // live will still be there when they press it (or on the next load). } })() return () => { cancelled = true } // Mount only: we are asking whether this browser is mid-flow, which cannot // change without one of this component's own actions. // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const handleCancel = useCallback(async () => { startGenRef.current++ // stop an in-flight startSession from resuming cleanup() setSession(null) // Hold a 'cancelling' state until /cancel resolves, rather than going // straight to 'idle'. /cancel clears the flow cookie with an UNTARGETED // Set-Cookie, and it round-trips to TIC before responding, so if the start // button were reachable now the user could open a new flow whose fresh // cookie the late clear would then delete. No start button is rendered in // 'cancelling', so a new /start cannot race the clear. setStatus('cancelling') try { await fetch(`${API_BASE}/cancel`, { method: 'POST', headers: activeFlowId ? { [FLOW_ID_HEADER]: activeFlowId } : undefined, }) } catch { // Unreachable server: the flow expires on its own. } finally { setActiveFlowId(null) setStatus('idle') } }, [activeFlowId, cleanup]) if (status === 'cancelling') { return (
{t('bankid_cancelling')}
) } if (status === 'resumable') { return (

{t('bankid_resume_title')}

{/* Deliberately does not say who: naming the holder would leak a stranger's identity to whoever sits down at a shared machine. Signup shows the verified name on the next step, after the person here has claimed the identification as theirs. */} {t('bankid_resume_description')}

) } if (status === 'complete') { return (
{t('bankid_completing')}
) } if (status === 'idle') { const label = mode === 'login' ? 'Logga in med BankID' : mode === 'link' ? 'Koppla BankID' : 'Skapa konto med BankID' return ( ) } if (status === 'service_unavailable') { return (

BankID är inte tillgängligt just nu

{mode === 'login' ? 'Logga in med e-post och lösenord nedan, eller använd "Glömt lösenord?" för en inloggningslänk via e-post.' : mode === 'signup' ? 'Skapa konto med e-post och lösenord nedan istället.' : 'Försök igen senare.'}

) } if (status === 'failed') { return (

{errorMessage}

) } const openBankIdOnDevice = () => { if (!session) return // Open BankID app on the same device via deep link // redirect=null tells BankID not to redirect after completion window.location.href = `bankid:///?autostarttoken=${session.autoStartToken}&redirect=null` } // Scanning / waiting for user return (
{session && !isMobile() && ( <> {/* A resumed tab never called /start, so it has the rotating QR tokens but no autostart token. Rendering the button anyway would deep-link `autostarttoken=` empty, which does nothing and says nothing. The QR above still works. */} {session.qrStartToken && session.qrStartSecret && session.autoStartToken && ( )} )} {isMobile() && (
{/* A resumed tab never launched anything, so saying "opening the BankID app" would be a lie followed by silence. */}

{launchedApp ? 'Öppnar BankID-appen...' : t('bankid_finish_in_app')}

)}

{hintMessage}

) } function BankIdIcon({ className }: { className?: string }) { return ( BankID ) }