'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 (
) } 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(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(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 = { 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 ( {t('loading_status')} ) } if (!status?.connected) { return (
{t('title')}
{status?.disabled && (

{t('disabled_message')}

)}

{t('connect_intro')}

{/* The skahmst consent-page note only matters when the user can actually reach that page: hidden while the feature is gated. */} {hasSkatteverket && (
{t.rich('skahmst_note', { code: (chunks) => {chunks}, })}
)} {!hasSkatteverket && ( Anslutning till Skatteverket kräver ett abonnemang. )}
) } 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 (
{t('title')} {status.expired ? ( {t('expired')} ) : ( {t('connected')} )}
{status.needsReconsent && (

{t('needs_reconsent_message')}

)}
{t('token_expires_label')}
{expiresAtDate.toLocaleString('sv-SE')} {!status.expired && expiresInMinutes > 0 && ( {t('expires_in_minutes', { minutes: expiresInMinutes })} )}
{t('refresh_label')}
{status.canRefresh ? t('refresh_auto') : t('refresh_exhausted')}

{t('permissions_label')}

{scopes.map(s => ( {SCOPE_LABELS[s] ?? s} ))}
{!scopes.includes('skahmst') && !scopes.includes('skattekonto') && (

{t('missing_skattekonto')}

)} {!scopes.includes('agd') && (

{t('missing_agd')}

)}
{status.disabled && (

{t('disabled_filings_message')}

)}
{/* 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')) && ( )}
) } 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(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 ( {t('system_status_granted')} ) case 'denied': return {t('system_status_denied')} case 'error': return {t('system_status_error')} default: return {t('system_status_unknown')} } } return ( {t('system_title')}

{t('system_intro')}

{state.ombud_org_number && (

{t('system_org_label')}

{state.ombud_org_number}

)}
{t('system_behorighet_lasombud')} {grantBadge(state.connection?.lasombud_status)}
{t('system_behorighet_moms')} {grantBadge(state.connection?.moms_ombud_status)}
{state.cert?.expiresSoon && (

{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry })}

)}
{state.grant_url && ( )}
) } function EnvironmentBadge({ environment, disabled }: { environment?: Environment; disabled?: boolean }) { const t = useTranslations('settings_skatteverket_connect') if (disabled) { return ( {t('env_disabled')} ) } if (environment === 'test') { return ( {t('env_test')} ) } if (environment === 'prod') { return ( {t('env_prod')} ) } return null }