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
This commit is contained in:
Mattsson
2026-05-10 00:14:55 +02:00
committed by GitHub
parent 993b3f5962
commit efdd3a50ba
2 changed files with 166 additions and 24 deletions
+104 -8
View File
@@ -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) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 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 && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">Anslutningen mot Skatteverket har gått ut</p>
<p className="mt-1 text-xs text-muted-foreground">
Logga in med BankID igen för att kunna skicka AGI.
</p>
<Button size="sm" variant="outline" className="mt-2" onClick={handleConnect}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
Återanslut med BankID
</Button>
</div>
)}
{/* 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) {
</div>
)}
{error && (
<div className="rounded-md bg-destructive/10 p-2.5 text-sm text-destructive">
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{error}
</div>
)}
{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 (
<div className="rounded-md bg-destructive/10 p-2.5 text-sm text-destructive">
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{error}
{sessionExpired && !readOnly && (
<div className="mt-2">
<Button size="sm" variant="outline" onClick={handleConnect}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
Återanslut med BankID
</Button>
</div>
)}
</div>
)
})()}
{success && !error && (
<div className="rounded-md bg-emerald-50 p-2.5 text-sm text-emerald-900 dark:bg-emerald-900/20 dark:text-emerald-300">
<CheckCircle2 className="mr-1 inline h-3.5 w-3.5" />
+62 -16
View File
@@ -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 <script>. Escapes quotes and `</`
// so the value can't break out of the script tag.
const jsLiteral = (value: unknown) =>
JSON.stringify(value ?? '').replace(/</g, '\\u003c')
// Build an HTML response that detects whether we're running inside an
// OAuth popup. If `window.opener` exists, post a message back to the
// parent and close the popup. Otherwise fall back to a plain redirect
// (preserves the legacy non-popup connect flow).
const respondWithSuccess = (fallbackPath: string) => {
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'skatteverket-oauth-success' }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.href = ${jsLiteral(`${appUrl}${fallbackPath}`)};
}
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'skatteverket-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.href = ${jsLiteral(`${appUrl}${fallbackPath}`)};
}
</script><p>Anslutningen misslyckades: ${escapedReason}</p></body></html>`
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))
}
},
},