'use client' import { useTranslations } from 'next-intl' import { useEffect, 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') const { toast } = useToast() const hasSkatteverket = useCapability(CAPABILITY.skatteverket) const [status, setStatus] = useState(null) const [loading, setLoading] = useState(true) const [disconnecting, setDisconnecting] = useState(false) // 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'), } async function loadStatus() { 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() }, []) function startConnect() { const returnTo = encodeURIComponent('/settings/tax') window.location.href = `/api/extensions/ext/skatteverket/authorize?return_to=${returnTo}` } 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() } 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')}

)}
{(status.expired || status.needsReconsent || !status.canRefresh || !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 }