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:
@@ -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" />
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
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))
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user