* fix(settings): open Skatteverket connect in a popup and fix the skahmst scope check The full-page OAuth round-trip left Skatteverket's pages and the consumed callback URL in browser history directly beneath /settings/tax, so closing settings walked Back into a dead OAuth chain and re-prompted BankID auth. The connect buttons now use the AGIPanel popup + postMessage pattern (the callback already supports window.opener); the page never navigates and the panel refetches status on success. Full-page navigation remains only as the popup-blocked fallback. Also fixes the reconnect-button condition: it checked for a scope literally named 'skattekonto', but SKV grants 'skahmst', which kept "Anslut igen" permanently visible on healthy connections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(skatteverket): extract per-declaration AGI kvittens reconciliation Behavior-identical extraction of the kvittens cron's core (fetch kvittens, promote declaration to submitted, stamp salary_runs, clear cached submission, complete deadline, notify) into lib/agi-kvittens-reconcile.ts so the upcoming post-connect refresh can reuse it. Auth-error mapping (needs_reconsent, grant revocation, APIGW config gaps) and run-level logging stay in the cron, which is why SkatteverketAuthError propagates out of the helper on purpose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): auto-settle production-format AGI skattekonto draws The settlement matcher only understood the SKV test environment's single combined row ("Arbetsgivardeklaration YYYYMM"). Production books the draw as two rows with Swedish month names: "Avdragen skatt maj 2026" (= total_tax) and "Arbetsgivaravgift maj 2026" (= total_avgifter), so auto-settlement has never fired against production data and the salary page kept showing paid periods as unpaid. parseAgiPeriod now also reads the month-name form (which additionally lets match suggestions resolve the period on prod rows, including beslut rows). Settlement classifies draws with start-anchored regexes so correction rows like "Beslut 260703 arbetsgivaravgift mars 2026" can never qualify, and settles a period on either the combined row matching the whole declared amount or exactly one tax row + exactly one avgift row matching their respective totals to the ore, dated to the later of the pair. Anything non-exact (partial draws, duplicates) still falls back to the manual mark-paid button. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skatteverket): refresh skattekonto + kvittenser right after OAuth consent Nothing fetched Skatteverket data after a (re)connect: tokens were stored and the user redirected, with the next fetch left to the nightly cron. SKV's per-flow tokens (and refresh tokens) live ~65 minutes, so the crons usually find them dead; right after consent is the one reliable window for a personal-token fetch, which is why reconnecting never made stale numbers recover. The callback now awaits runPostConnectRefresh after storing tokens: a skattekonto sync (upsert + auto-settlement + balance snapshot) plus a kvittens re-check for this company's pending_signature AGI declarations. Awaited on purpose so popup-close means the data is already fresh and UI refetch listeners never race a background job; every step is best-effort and a refresh failure can never fail the connect that just succeeded. The callback's non-popup fallback also switches to window.location.replace so the consumed callback URL (one-shot code + state) drops out of history instead of re-running into a guaranteed CSRF error on Back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(salary): reload the salary dashboard after a Skatteverket reconnect Listens for the BankID popup's skatteverket-oauth-success message and re-runs load(). Because the OAuth callback awaits the skattekonto sync and AGI auto-settlement before responding, the refetch already sees settled tax-payment state: the "Skatt att betala" card flips to paid the moment the popup closes, without leaving the page. Also logs the three decisions behind this series in DECISIONS.md (awaited post-connect refresh over after(), exact-pair settlement over per-period summing, popup flow over in-place history repair). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): verify OAuth popup source identity before trusting postMessage The three 'skatteverket-oauth-success' listeners accepted any same-origin message, letting any same-origin script trigger a data reload or a fake success toast (OWASP ASVS V3.2, SOC 2 CC6.1). The two components that open the popup now keep its handle in a ref and require event.source to be that exact window; a window reference cannot be forged by same-origin scripts, which is strictly stronger than a nonce threaded through the OAuth flow. The salary dashboard never opens the popup, so its raw message listener is replaced by a 'skatteverket-connection-updated' CustomEvent dispatched only after a component has source-verified the popup (and after disconnect, so connection-state consumers stay in sync). Also extends the connect consent copy (sv + en) to disclose that connecting immediately fetches skattekonto data and checks pending AGI receipts (GDPR Art.5(1)(b) transparency). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): CSP nonce and no-store on OAuth callback, deadline on post-connect refresh Both callback HTML responses now carry a per-response nonce CSP (default-src 'none'; script-src 'nonce-...'; base-uri 'none'; form-action 'none') so injected markup could never execute, plus Cache-Control: no-store because the callback URL carries a one-shot authorization code. The jsLiteral/appUrl injection invariants are documented at the definition site. The awaited post-connect refresh is bounded by a 30-second Promise.race deadline so a hung SKV call cannot hold the OAuth callback open; on timeout the refresh continues best-effort and the user still gets the success response. Refresh failures and timeouts now log through the structured logger with companyId + userId so they are attributable in log aggregation (ASVS V16, ISO 27001 A.8.15, SOC 2 CC8.1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): tenant guard and redacting logs in kvittens reconcile, slim cron response The agi_declarations update now also filters on company_id (ASVS V8.2.1), matching the salary_runs pattern. The reconciler and the cron's error paths log through the structured logger so third-party error strings pass personnummer redaction; uuidKvittens is dropped from log context (GDPR Art.5(1)(f) minimization, declarationId suffices). response_data gains submittedAtEstimated so the signeradTid fallback can never be mistaken for the legal filing time, and the submitted_by comment now states explicitly that it records the technical submitter while response_data.signeradAv is the authoritative legal signatory (BFL 5 kap 6 par, BFNAR 2013:2 kap 8). The cron HTTP response omits companyId per result row and sends Cache-Control: no-store; the extension_data delete documents why the period-scoped cache key needs no declaration-id guard. Cron tests observe the logger via a mock without weakening any assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): diagnosable settlement refusals and signeradAv ROPA documentation Settlement refusals (candidates present but amounts mismatch) now emit a structured info log with declared-vs-drawn ore amounts per kind, so a rounding divergence between stored declaration totals and SKV's actual draw is diagnosable instead of silently falling back to the manual button. No transaction texts are logged (they can carry personal data). parseAgiPeriod documents the beslut-row audit: correction rows parse to their period on purpose for match-suggestion boosting; settlement never uses parseAgiPeriod (anchored classifiers + parseNumericAgiPeriod only) and the only callers require an exact amount+side 1630 match first. .compliance/ropa.yaml documents signeradAv (signer personnummer in the SKV kvittens stored in agi_declarations.response_data): lawful basis Art.6(1)(c) via BFL 5 kap 6 par / BFNAR 2013:2 kap 8, 7-year retention per BFL 7 kap 2 par, access via company-membership RLS. DECISIONS.md records the accepted-with-documentation calls from the compliance review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): add 'already_claimed' status to reconcile outcomes and enhance logging for pending lookups --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
502 lines
18 KiB
TypeScript
502 lines
18 KiB
TypeScript
'use client'
|
|
|
|
import { useTranslations } from 'next-intl'
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { useCapability } from '@/contexts/CompanyContext'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
|
import { CheckCircle2, ExternalLink, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react'
|
|
|
|
type Environment = 'test' | 'prod'
|
|
|
|
type Status =
|
|
| { connected: false; environment?: Environment; disabled?: boolean }
|
|
| {
|
|
connected: true
|
|
expired: boolean
|
|
canRefresh: boolean
|
|
needsReconsent?: boolean
|
|
lastErrorCode?: string | null
|
|
scope: string
|
|
expiresAt: string
|
|
environment?: Environment
|
|
disabled?: boolean
|
|
}
|
|
|
|
export function SkatteverketConnectPanel() {
|
|
return (
|
|
<div className="space-y-4">
|
|
<SkatteverketPersonalConnectionCard />
|
|
<SkatteverketSystemConnectionCard />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SkatteverketPersonalConnectionCard() {
|
|
const t = useTranslations('settings_skatteverket_connect')
|
|
// Toast strings shared with TaxSettingsContent's query-param fallback path.
|
|
const tOauth = useTranslations('settings_skatteverket')
|
|
const { toast } = useToast()
|
|
const hasSkatteverket = useCapability(CAPABILITY.skatteverket)
|
|
const [status, setStatus] = useState<Status | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [disconnecting, setDisconnecting] = useState(false)
|
|
// Handle of the OAuth popup opened by startConnect: used to verify the
|
|
// sender identity of incoming postMessages.
|
|
const popupRef = useRef<Window | null>(null)
|
|
|
|
// docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101
|
|
const SCOPE_LABELS: Record<string, string> = {
|
|
momsdeklaration: t('scope_momsdeklaration'),
|
|
inkforetag: t('scope_inkforetag'),
|
|
skahmst: t('scope_skahmst'),
|
|
skattekonto: t('scope_skattekonto'),
|
|
agd: t('scope_agd'),
|
|
}
|
|
|
|
const loadStatus = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch('/api/extensions/ext/skatteverket/status')
|
|
if (res.status === 503) {
|
|
setStatus({ connected: false })
|
|
return
|
|
}
|
|
const data = (await res.json()) as Status
|
|
setStatus(data)
|
|
} catch {
|
|
setStatus({ connected: false })
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadStatus()
|
|
}, [loadStatus])
|
|
|
|
// Listen for OAuth completion from the BankID popup (same pattern as
|
|
// AGIPanel): the callback page posts success/error and closes itself, so
|
|
// the settings page never navigates and we just re-fetch the status.
|
|
useEffect(() => {
|
|
function handleMessage(event: MessageEvent) {
|
|
if (event.origin !== window.location.origin) return
|
|
// Source-identity check: only the popup this component opened can
|
|
// trigger the handler; a window reference cannot be forged by other
|
|
// same-origin scripts.
|
|
if (!popupRef.current || event.source !== popupRef.current) return
|
|
if (event.data?.type === 'skatteverket-oauth-success') {
|
|
toast({
|
|
title: tOauth('connected_title'),
|
|
description: tOauth('connected_description'),
|
|
})
|
|
loadStatus()
|
|
// Verified success: rebroadcast as an internal DOM event so passive
|
|
// consumers (e.g. the salary page) can react without trusting raw
|
|
// postMessage.
|
|
window.dispatchEvent(new CustomEvent('skatteverket-connection-updated'))
|
|
} else if (event.data?.type === 'skatteverket-oauth-error') {
|
|
toast({
|
|
title: tOauth('connect_failed_title'),
|
|
description:
|
|
typeof event.data.reason === 'string' && event.data.reason
|
|
? event.data.reason
|
|
: undefined,
|
|
variant: 'destructive',
|
|
})
|
|
}
|
|
}
|
|
window.addEventListener('message', handleMessage)
|
|
return () => window.removeEventListener('message', handleMessage)
|
|
}, [loadStatus, toast, tOauth])
|
|
|
|
function startConnect() {
|
|
// Open the BankID OAuth flow in a centered popup. The callback page
|
|
// detects `window.opener`, posts back a message and closes itself: the
|
|
// settings page never navigates, so browser history stays clean and
|
|
// closing the settings afterwards cannot walk Back into the consumed
|
|
// OAuth chain (the "redirected to Skatteverket again" bug).
|
|
const returnTo = encodeURIComponent('/settings/tax')
|
|
const url = `/api/extensions/ext/skatteverket/authorize?return_to=${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}`,
|
|
)
|
|
popupRef.current = popup
|
|
if (!popup) {
|
|
// Popup blocked: fall back to the full-page flow. The callback then
|
|
// lands on /settings/tax?skv_connected=true, handled by
|
|
// TaxSettingsContent's query-param effect.
|
|
window.location.href = url
|
|
}
|
|
}
|
|
|
|
async function disconnect() {
|
|
setDisconnecting(true)
|
|
try {
|
|
const res = await fetch('/api/extensions/ext/skatteverket/disconnect', {
|
|
method: 'POST',
|
|
})
|
|
if (!res.ok) throw new Error(t('disconnect_failed'))
|
|
toast({ title: t('toast_disconnected') })
|
|
await loadStatus()
|
|
// Connection state changed: notify passive consumers via the same
|
|
// internal event as a verified OAuth success.
|
|
window.dispatchEvent(new CustomEvent('skatteverket-connection-updated'))
|
|
} catch (err) {
|
|
toast({
|
|
title: t('toast_disconnect_failed'),
|
|
description: err instanceof Error ? err.message : undefined,
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setDisconnecting(false)
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-8 text-sm text-muted-foreground">
|
|
{t('loading_status')}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
if (!status?.connected) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">{t('title')}</CardTitle>
|
|
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{status?.disabled && (
|
|
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
|
|
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
|
<p>{t('disabled_message')}</p>
|
|
</div>
|
|
)}
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('connect_intro')}
|
|
</p>
|
|
{/* The skahmst consent-page note only matters when the user can
|
|
actually reach that page: hidden while the feature is gated. */}
|
|
{hasSkatteverket && (
|
|
<div className="rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
|
|
{t.rich('skahmst_note', {
|
|
code: (chunks) => <span className="font-mono">{chunks}</span>,
|
|
})}
|
|
</div>
|
|
)}
|
|
{!hasSkatteverket && (
|
|
<UpgradeNote>Anslutning till Skatteverket kräver ett abonnemang.</UpgradeNote>
|
|
)}
|
|
<Button
|
|
onClick={startConnect}
|
|
disabled={status?.disabled || !hasSkatteverket}
|
|
title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
|
|
>
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
{t('connect_with_bankid')}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const scopes = (status.scope || '').split(/\s+/).filter(Boolean)
|
|
const expiresAtDate = new Date(status.expiresAt)
|
|
const expiresInMinutes = Math.round(
|
|
(expiresAtDate.getTime() - Date.now()) / 60_000,
|
|
)
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
{t('title')}
|
|
{status.expired ? (
|
|
<Badge variant="destructive">{t('expired')}</Badge>
|
|
) : (
|
|
<Badge variant="secondary">
|
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
{t('connected')}
|
|
</Badge>
|
|
)}
|
|
</CardTitle>
|
|
<EnvironmentBadge environment={status.environment} disabled={status.disabled} />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{status.needsReconsent && (
|
|
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
|
|
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
|
<p>{t('needs_reconsent_message')}</p>
|
|
</div>
|
|
)}
|
|
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
|
<div>
|
|
<dt className="text-muted-foreground">{t('token_expires_label')}</dt>
|
|
<dd className="font-medium tabular-nums">
|
|
{expiresAtDate.toLocaleString('sv-SE')}
|
|
{!status.expired && expiresInMinutes > 0 && (
|
|
<span className="ml-2 text-muted-foreground">
|
|
{t('expires_in_minutes', { minutes: expiresInMinutes })}
|
|
</span>
|
|
)}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-muted-foreground">{t('refresh_label')}</dt>
|
|
<dd className="font-medium">
|
|
{status.canRefresh ? t('refresh_auto') : t('refresh_exhausted')}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div>
|
|
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
|
{t('permissions_label')}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{scopes.map(s => (
|
|
<Badge key={s} variant="outline">
|
|
{SCOPE_LABELS[s] ?? s}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
{!scopes.includes('skahmst') && !scopes.includes('skattekonto') && (
|
|
<p className="mt-3 text-sm text-foreground">
|
|
{t('missing_skattekonto')}
|
|
</p>
|
|
)}
|
|
{!scopes.includes('agd') && (
|
|
<p className="mt-3 text-sm text-foreground">
|
|
{t('missing_agd')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{status.disabled && (
|
|
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
|
|
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
|
<p>{t('disabled_filings_message')}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
{/* The skattekonto read scope is named `skahmst` in the live grants;
|
|
accept the older `skattekonto` name too (mirrors the missing-scope
|
|
notice above). Checking only `skattekonto` kept this button
|
|
permanently visible on healthy connections. */}
|
|
{(status.expired || status.needsReconsent || !status.canRefresh || !(scopes.includes('skahmst') || scopes.includes('skattekonto')) || !scopes.includes('agd')) && (
|
|
<Button
|
|
onClick={startConnect}
|
|
disabled={status.disabled || !hasSkatteverket}
|
|
title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
|
|
>
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
{t('reconnect')}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
onClick={disconnect}
|
|
disabled={disconnecting}
|
|
>
|
|
<ShieldOff className="mr-2 h-4 w-4" />
|
|
{disconnecting ? t('disconnecting') : t('disconnect')}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
type GrantStatus = 'unknown' | 'granted' | 'denied' | 'error'
|
|
|
|
interface SystemConnectionState {
|
|
available: boolean
|
|
mode?: string
|
|
environment?: string
|
|
ombud_org_number?: string | null
|
|
grant_url?: string
|
|
cert?: { notAfter: string; daysUntilExpiry: number; expiresSoon: boolean } | null
|
|
connection?: {
|
|
status: string
|
|
lasombud_status: GrantStatus
|
|
moms_ombud_status: GrantStatus
|
|
verified_at: string | null
|
|
last_probe_at: string | null
|
|
} | null
|
|
}
|
|
|
|
/**
|
|
* The system (ombud + organization certificate) connection: the one-time
|
|
* grant that lets background syncs run without a personal BankID session.
|
|
* Renders nothing until SKATTEVERKET_SYSTEM_AUTH_MODE is switched on
|
|
* server-side, so the whole section is invisible during Phase 1.
|
|
*/
|
|
function SkatteverketSystemConnectionCard() {
|
|
const t = useTranslations('settings_skatteverket_connect')
|
|
const { toast } = useToast()
|
|
const [state, setState] = useState<SystemConnectionState | null>(null)
|
|
const [verifying, setVerifying] = useState(false)
|
|
|
|
async function loadState() {
|
|
try {
|
|
const res = await fetch('/api/extensions/ext/skatteverket/system-connection')
|
|
if (!res.ok) {
|
|
setState({ available: false })
|
|
return
|
|
}
|
|
setState((await res.json()) as SystemConnectionState)
|
|
} catch {
|
|
setState({ available: false })
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadState()
|
|
}, [])
|
|
|
|
async function verify() {
|
|
setVerifying(true)
|
|
try {
|
|
const res = await fetch('/api/extensions/ext/skatteverket/system-connection/verify', {
|
|
method: 'POST',
|
|
})
|
|
const body = await res.json().catch(() => ({}))
|
|
if (res.status === 429) {
|
|
toast({ title: t('system_verify_rate_limited') })
|
|
return
|
|
}
|
|
if (!res.ok) {
|
|
toast({
|
|
title: t('system_verify_failed'),
|
|
description: typeof body?.error === 'string' ? body.error : undefined,
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
await loadState()
|
|
} catch {
|
|
toast({ title: t('system_verify_failed'), variant: 'destructive' })
|
|
} finally {
|
|
setVerifying(false)
|
|
}
|
|
}
|
|
|
|
if (!state?.available) return null
|
|
|
|
const grantBadge = (status: GrantStatus | undefined) => {
|
|
switch (status) {
|
|
case 'granted':
|
|
return (
|
|
<Badge variant="success">
|
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
{t('system_status_granted')}
|
|
</Badge>
|
|
)
|
|
case 'denied':
|
|
return <Badge variant="destructive">{t('system_status_denied')}</Badge>
|
|
case 'error':
|
|
return <Badge variant="warning">{t('system_status_error')}</Badge>
|
|
default:
|
|
return <Badge variant="outline">{t('system_status_unknown')}</Badge>
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">{t('system_title')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">{t('system_intro')}</p>
|
|
|
|
{state.ombud_org_number && (
|
|
<div className="rounded-md border border-border bg-secondary/40 p-3 text-sm">
|
|
<p className="text-muted-foreground">{t('system_org_label')}</p>
|
|
<p className="font-mono font-medium tabular-nums">{state.ombud_org_number}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
|
<div className="flex items-center justify-between gap-2 rounded-md border border-border p-3">
|
|
<span>{t('system_behorighet_lasombud')}</span>
|
|
{grantBadge(state.connection?.lasombud_status)}
|
|
</div>
|
|
<div className="flex items-center justify-between gap-2 rounded-md border border-border p-3">
|
|
<span>{t('system_behorighet_moms')}</span>
|
|
{grantBadge(state.connection?.moms_ombud_status)}
|
|
</div>
|
|
</div>
|
|
|
|
{state.cert?.expiresSoon && (
|
|
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
|
|
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
|
<p>{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry })}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
{state.grant_url && (
|
|
<Button variant="outline" asChild>
|
|
<a href={state.grant_url} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
{t('system_open_ombud')}
|
|
</a>
|
|
</Button>
|
|
)}
|
|
<Button onClick={verify} disabled={verifying}>
|
|
{verifying ? t('system_verifying') : t('system_verify')}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function EnvironmentBadge({ environment, disabled }: { environment?: Environment; disabled?: boolean }) {
|
|
const t = useTranslations('settings_skatteverket_connect')
|
|
if (disabled) {
|
|
return (
|
|
<Badge variant="destructive">
|
|
<ShieldAlert className="mr-1 h-3 w-3" />
|
|
{t('env_disabled')}
|
|
</Badge>
|
|
)
|
|
}
|
|
if (environment === 'test') {
|
|
return (
|
|
<Badge variant="warning">
|
|
<FlaskConical className="mr-1 h-3 w-3" />
|
|
{t('env_test')}
|
|
</Badge>
|
|
)
|
|
}
|
|
if (environment === 'prod') {
|
|
return (
|
|
<Badge variant="success">
|
|
{t('env_prod')}
|
|
</Badge>
|
|
)
|
|
}
|
|
return null
|
|
}
|