From efdd3a50ba419c1041bff158b6ea42d69aefc6e1 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 10 May 2026 00:14:55 +0200 Subject: [PATCH] Bug/stale skv connection (#427) * fix(dashboard): enable salary features with "Beta" badge for testing * fix(errors): enhance Swedish error message patterns for better user feedback fix(salary): update Nordea Personkonto handling in account encoding logic * fix(agi): implement feature flag for AGI transmission and update button states * fix(swedish-payroll): update youth rate eligibility criteria and enhance documentation * fix(agi-panel): remove feature flag for AGI transmission and simplify button states * fix(agi-panel): handle stale session errors and improve reconnect flow --- components/salary/AGIPanel.tsx | 112 +++++++++++++++++++++-- extensions/general/skatteverket/index.ts | 78 ++++++++++++---- 2 files changed, 166 insertions(+), 24 deletions(-) diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx index c0caf9dc..5539fd20 100644 --- a/components/salary/AGIPanel.tsx +++ b/components/salary/AGIPanel.tsx @@ -130,7 +130,22 @@ export function AGIPanel(props: AGIPanelProps) { } } if (res.ok) { - setStatus(await res.json()) + const next = await res.json() as ConnectionStatus + setStatus(next) + // Clear stale session-expired error after a successful reconnect. + // The browser bfcache can restore React state from before the OAuth + // round-trip, leaving the old "Sessionen har gått ut" message in + // place even though the token is now fresh. This wipes the error + // only when (a) there's currently an error and (b) the new status + // says we're healthy — never silently swallowing unrelated errors. + const isHealthy = next.connected && !next.expired && next.canRefresh !== false + if (isHealthy) { + setError(prev => + prev && /sessionen har gått ut|logga in med bankid igen/i.test(prev) + ? null + : prev, + ) + } } } catch { // ignore — UI shows the not-connected state @@ -158,6 +173,28 @@ export function AGIPanel(props: AGIPanelProps) { fetchSubmission() }, [fetchStatus, fetchSubmission]) + // Listen for OAuth completion from the BankID popup. When the popup posts + // back a success/error message we re-fetch status so the panel flips from + // "expired" / not-connected to "Ansluten" without a full page reload. + useEffect(() => { + function handleMessage(event: MessageEvent) { + if (event.origin !== window.location.origin) return + if (event.data?.type === 'skatteverket-oauth-success') { + setError(null) + setSuccess('Anslutningen mot Skatteverket lyckades.') + fetchStatus() + } else if (event.data?.type === 'skatteverket-oauth-error') { + const reason = + typeof event.data.reason === 'string' && event.data.reason + ? event.data.reason + : 'OAuth-anslutningen misslyckades. Försök igen.' + setError(reason) + } + } + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [fetchStatus]) + // Background kvittens-polling timers (see scheduleKvittensPolls below). // Held in a ref so the unmount-cleanup effect can cancel them if the // user leaves the page mid-signing. @@ -213,7 +250,31 @@ export function AGIPanel(props: AGIPanelProps) { }, [arbetsgivare, period, fetchSubmission, onChange]) const handleConnect = () => { - window.location.href = '/api/extensions/ext/skatteverket/authorize' + // Open the BankID OAuth flow in a centered popup. The callback page + // detects `window.opener` and posts back a `skatteverket-oauth-success` + // (or `-error`) message, then closes itself — see the postMessage + // listener below. `return_to` is still passed so the popup-less fallback + // path (e.g. popup blockers) lands on the salary run page rather than + // the default /reports tab. + const returnTo = typeof window !== 'undefined' + ? window.location.pathname + window.location.search + : '' + const url = `/api/extensions/ext/skatteverket/authorize${ + returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : '' + }` + const w = 600 + const h = 750 + const left = window.screenX + (window.outerWidth - w) / 2 + const top = window.screenY + (window.outerHeight - h) / 2 + const popup = window.open( + url, + 'skatteverket-oauth', + `width=${w},height=${h},left=${left},top=${top}`, + ) + if (!popup) { + // Popup blocked — fall back to a full-page navigation. + window.location.href = url + } } /** @@ -509,6 +570,23 @@ export function AGIPanel(props: AGIPanelProps) { + {/* Expired-session banner — the token row exists (so status.connected + is true) but the access token is past expiry and either has no + refresh token or has burned through its 10-refresh budget. The + only fix is a fresh BankID round-trip. */} + {(status?.expired === true || status?.canRefresh === false) && !readOnly && ( +
+

Anslutningen mot Skatteverket har gått ut

+

+ Logga in med BankID igen för att kunna skicka AGI. +

+ +
+ )} + {/* Missing-scope banner — proactive nudge before the user hits a 403 invalid_scope at submission time. The agd scope was added after some users had already connected, so their stored token @@ -624,12 +702,30 @@ export function AGIPanel(props: AGIPanelProps) { )} - {error && ( -
- - {error} -
- )} + {error && (() => { + // When the underlying token is expired or its refresh budget is + // exhausted, the only fix is for the user to re-do the BankID OAuth + // flow. Surface a reconnect button right next to the error so they + // don't have to hunt for it in settings. + const sessionExpired = + /sessionen har gått ut|logga in med bankid igen/i.test(error) || + status?.expired === true || + status?.canRefresh === false + return ( +
+ + {error} + {sessionExpired && !readOnly && ( +
+ +
+ )} +
+ ) + })()} {success && !error && (
diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index 85563846..5fb870b2 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -137,16 +137,61 @@ export const skatteverketExtension: Extension = { const state = url.searchParams.get('state') const error = url.searchParams.get('error') + // JSON-encode for safe embedding inside

Anslutningen lyckades. Du kan stänga denna flik.

` + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }) + } + + const respondWithError = (reason: string, fallbackPath: string) => { + const escapedReason = reason + .replace(/&/g, '&') + .replace(//g, '>') + const html = `

Anslutningen misslyckades: ${escapedReason}

` + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }) + } + if (error) { const desc = url.searchParams.get('error_description') || 'Okänt fel' - return NextResponse.redirect( - `${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent(desc)}` + return respondWithError( + desc, + `/reports?tab=vat-declaration&skv_error=${encodeURIComponent(desc)}`, ) } if (!code || !state) { - return NextResponse.redirect( - `${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Saknar auktoriseringskod')}` + return respondWithError( + 'Saknar auktoriseringskod', + `/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Saknar auktoriseringskod')}`, ) } @@ -158,6 +203,9 @@ export const skatteverketExtension: Extension = { // Verify user session (browser should still have cookies) const { data: { user } } = await supabase.auth.getUser() if (!user) { + // Login redirects always go to a full page — popups can't render the + // login form usefully, so this is the one path that keeps a hard + // redirect even from inside the popup. return NextResponse.redirect( `${appUrl}/login?redirect=${encodeURIComponent('/reports?tab=vat-declaration')}` ) @@ -169,8 +217,9 @@ export const skatteverketExtension: Extension = { try { companyId = await requireCompanyId(supabase, user.id) } catch { - return NextResponse.redirect( - `${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Inget företag valt')}` + return respondWithError( + 'Inget företag valt', + `/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Inget företag valt')}`, ) } @@ -184,8 +233,9 @@ export const skatteverketExtension: Extension = { .single() if (!settingsData || settingsData.value !== state) { - return NextResponse.redirect( - `${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ogiltig state-parameter (CSRF)')}` + return respondWithError( + 'Ogiltig state-parameter (CSRF)', + `/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ogiltig state-parameter (CSRF)')}`, ) } @@ -213,11 +263,11 @@ export const skatteverketExtension: Extension = { const returnTo = (returnToData?.value as string | null) || null const successPath = returnTo ? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_connected=true` - : `${appUrl}/reports?tab=vat-declaration&skv_connected=true` + : `/reports?tab=vat-declaration&skv_connected=true` const errorPath = (msg: string) => returnTo ? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_error=${encodeURIComponent(msg)}` - : `${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent(msg)}` + : `/reports?tab=vat-declaration&skv_error=${encodeURIComponent(msg)}` try { const tokens = await exchangeCodeForTokens(code, redirectUri) @@ -231,10 +281,7 @@ export const skatteverketExtension: Extension = { .eq('extension_id', 'skatteverket') .in('key', ['oauth_state', 'oauth_return_to']) - const success = returnTo - ? `${appUrl}${successPath}` - : successPath - return NextResponse.redirect(success) + return respondWithSuccess(successPath) } catch (err) { console.error('[skatteverket] Token exchange failed:', err) // BankID auth codes expire after 5 minutes. Surface timeouts distinctly @@ -244,8 +291,7 @@ export const skatteverketExtension: Extension = { : err instanceof Error ? err.message : 'Token exchange misslyckades' - const target = returnTo ? `${appUrl}${errorPath(message)}` : errorPath(message) - return NextResponse.redirect(target) + return respondWithError(message, errorPath(message)) } }, },