Bug/skv connection (#430)
* feat(invoices): implement öresavrundning logic and next invoice number preview - Added `getDisplayTotal` utility to handle rounding for SEK invoices based on company settings. - Updated `InvoicesPage` to utilize the new rounding logic when displaying totals. - Introduced `peek_next_invoice_number` function to allow previewing the next invoice number without consuming the sequence. - Modified invoice number generation to remove the year prefix and prevent truncation of numbers exceeding three digits. - Enhanced tests for invoice number generation and rounding functionality to ensure correctness. - Updated PDF template to reflect new rounding logic for totals and display appropriate values. - Adjusted company switcher to hide options in sandbox mode. - Improved error handling and logging in sandbox seeding process. * fix(skatteverket): remove unused scope labels from SCOPE_LABELS and DEFAULT_SCOPES * feat(skatteverket): add token revocation handling and disconnect functionality
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
Link2Off,
|
||||
Loader2,
|
||||
Lock,
|
||||
PlugZap,
|
||||
Send,
|
||||
ShieldAlert,
|
||||
Unlock,
|
||||
@@ -249,6 +250,29 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
kvittensTimers.current.push(setTimeout(poll, 300_000))
|
||||
}, [arbetsgivare, period, fetchSubmission, onChange])
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setActionLoading('disconnect')
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/skatteverket/disconnect', {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}))
|
||||
setError(json.error || `Kunde inte koppla bort (${res.status})`)
|
||||
return
|
||||
}
|
||||
setSuccess('Anslutningen mot Skatteverket har kopplats bort.')
|
||||
await fetchStatus()
|
||||
await fetchSubmission()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Kunde inte koppla bort')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}, [fetchStatus, fetchSubmission])
|
||||
|
||||
const handleConnect = () => {
|
||||
// Open the BankID OAuth flow in a centered popup. The callback page
|
||||
// detects `window.opener` and posts back a `skatteverket-oauth-success`
|
||||
@@ -563,9 +587,27 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>Arbetsgivardeklaration (AGI)</span>
|
||||
<span className="flex items-center gap-1 text-xs font-normal text-muted-foreground">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
|
||||
Ansluten
|
||||
<span className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
|
||||
Ansluten
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDisconnect}
|
||||
disabled={actionLoading === 'disconnect'}
|
||||
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground transition-colors hover:border-destructive/50 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Koppla bort anslutningen mot Skatteverket"
|
||||
>
|
||||
{actionLoading === 'disconnect' ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<PlugZap className="h-3 w-3" />
|
||||
)}
|
||||
Koppla bort
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// Mock the token-store to bypass DB and supply a fresh access token.
|
||||
const deleteTokensMock = vi.fn()
|
||||
vi.mock('../lib/token-store', () => ({
|
||||
getTokens: vi.fn(async () => ({
|
||||
access_token: 'test-access',
|
||||
@@ -10,7 +11,7 @@ vi.mock('../lib/token-store', () => ({
|
||||
scope: 'momsdeklaration',
|
||||
})),
|
||||
storeTokens: vi.fn(),
|
||||
deleteTokens: vi.fn(),
|
||||
deleteTokens: (...args: unknown[]) => deleteTokensMock(...args),
|
||||
}))
|
||||
|
||||
// Mock oauth so a refresh attempt (shouldn't fire) is harmless.
|
||||
@@ -66,6 +67,20 @@ describe('skvRequest — error mapping', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('maps 401 with "Token has been revoked." body → TOKEN_REVOKED and clears local row', async () => {
|
||||
deleteTokensMock.mockClear()
|
||||
mockFetchStatus(401, '{"error":"Token has been revoked."}')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
expect((e as SkatteverketAuthError).code).toBe('TOKEN_REVOKED')
|
||||
expect((e as SkatteverketAuthError).message).toMatch(/återkallat/i)
|
||||
expect(deleteTokensMock).toHaveBeenCalledWith(fakeSupabase, 'user-1')
|
||||
}
|
||||
})
|
||||
|
||||
it('maps 401 with WWW-Authenticate insufficient_scope → MISSING_SCOPE', async () => {
|
||||
mockFetchStatus(401, '', {
|
||||
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"',
|
||||
|
||||
@@ -1584,6 +1584,7 @@ function handleSkvError(err: unknown): NextResponse {
|
||||
: err.code === 'SESSION_EXPIRED' || err.code === 'REFRESH_EXHAUSTED' ? 401
|
||||
: err.code === 'MISSING_SCOPE' ? 401
|
||||
: err.code === 'TOKEN_CORRUPTED' ? 401
|
||||
: err.code === 'TOKEN_REVOKED' ? 401
|
||||
: 403
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from 'crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { refreshAccessToken } from './oauth'
|
||||
import { getTokens, storeTokens } from './token-store'
|
||||
import { getTokens, storeTokens, deleteTokens } from './token-store'
|
||||
import type { SkatteverketTokens } from '../types'
|
||||
|
||||
/**
|
||||
@@ -244,6 +244,7 @@ export async function skvRequest(
|
||||
? ` Headers: ${JSON.stringify(skvHeaders)}`
|
||||
: ''
|
||||
const bodySuffix = text ? ` Svar: ${text}` : ''
|
||||
const lower = text.toLowerCase()
|
||||
|
||||
// OAuth's standard insufficient_scope marker. SKV sometimes emits this
|
||||
// as 401 (rather than 403) when the AGI APIGW evaluates scope before
|
||||
@@ -263,10 +264,33 @@ export async function skvRequest(
|
||||
)
|
||||
}
|
||||
|
||||
// SKV explicitly declares the token revoked. Body shape observed in
|
||||
// production: { "error": "Token has been revoked." } with a generic
|
||||
// `Bearer realm="OAuth2 Client Realm"` challenge header. This is a
|
||||
// terminal state — the bearer will never come back to life, regardless
|
||||
// of refresh attempts (refresh_token from the same family is also dead).
|
||||
// Auto-clear the local row so /status stops claiming we're connected
|
||||
// and the next interaction forces a clean reconnect. We swallow any
|
||||
// delete error: even if cleanup fails we still want to surface the
|
||||
// primary auth error to the user.
|
||||
if (lower.includes('revoked') || lower.includes('token has been revoked')) {
|
||||
try {
|
||||
await deleteTokens(supabase, userId)
|
||||
} catch (cleanupErr) {
|
||||
console.error('[skatteverket] failed to clear revoked token row', cleanupErr)
|
||||
}
|
||||
throw new SkatteverketAuthError(
|
||||
'Skatteverket har återkallat anslutningen. Detta händer t.ex. om ' +
|
||||
'BankID-sessionen avslutats eller om en ny anslutning gjorts från ' +
|
||||
'en annan enhet. Anslut igen med BankID för att fortsätta.' +
|
||||
headerSuffix + bodySuffix,
|
||||
'TOKEN_REVOKED'
|
||||
)
|
||||
}
|
||||
|
||||
// APIGW subscription / client-credential problems: the gateway responds
|
||||
// before the bearer is ever evaluated. The user reconnecting won't help
|
||||
// here — it's an Utvecklarportalen / APIGW configuration issue.
|
||||
const lower = text.toLowerCase()
|
||||
const looksLikeApigwIssue =
|
||||
lower.includes('client_id') ||
|
||||
lower.includes('client id') ||
|
||||
@@ -379,6 +403,10 @@ export async function skvRequest(
|
||||
* NOT_CONNECTED — no tokens stored; user needs to run BankID flow
|
||||
* SESSION_EXPIRED — 401 from SKV; refresh exhausted or token rejected
|
||||
* REFRESH_EXHAUSTED — refresh count hit cap (10) before user re-auth
|
||||
* TOKEN_REVOKED — 401 with "Token has been revoked." body; SKV killed
|
||||
* the bearer (BankID session ended, parallel connect
|
||||
* from another device, or auth-code reuse). Local row
|
||||
* is auto-cleared; user must reconnect with BankID.
|
||||
* BEHORIGHET_SAKNAS — 403 with "Behörighet" body; user not authorized
|
||||
* for this company at SKV (firmatecknare / ombud)
|
||||
* MISSING_SCOPE — 403 with "invalid_scope" body; the stored token
|
||||
@@ -396,6 +424,7 @@ export class SkatteverketAuthError extends Error {
|
||||
| 'NOT_CONNECTED'
|
||||
| 'SESSION_EXPIRED'
|
||||
| 'REFRESH_EXHAUSTED'
|
||||
| 'TOKEN_REVOKED'
|
||||
| 'BEHORIGHET_SAKNAS'
|
||||
| 'MISSING_SCOPE'
|
||||
| 'ACCESS_DENIED'
|
||||
|
||||
Reference in New Issue
Block a user