'use client' import { useCallback, useEffect, useState } from 'react' import { useLocale, useTranslations } from 'next-intl' import { Loader2, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { SettingsGroup, SettingsRow, SettingsRowEnd, SettingsRowNote, } from '@/components/settings/SettingsRows' import { isSelfHosted } from '@/lib/env/public-flags' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' type UpstreamMode = 'connector' | 'own_credentials' | 'unconfigured' interface ConnectorStatus { configured: boolean key_prefix: string | null upstreams: { bank: UpstreamMode; skatteverket: UpstreamMode } granted_capabilities: string[] } interface SyncResult { outcome: 'not_configured' | 'synced' | 'revoked' | 'network_error' | 'server_error' grantsUpserted: number scopes?: string[] } /** * Settings -> Abonnemang, self-host only: is the connector wired, per * upstream, and a "Synka nu" that runs the entitlement sync on demand * instead of waiting for the hourly cron (PR6b-3). Renders nothing on * hosted; the status endpoint's self_hosted:false answer is the backstop. * * The sync covers every company on the instance; the capability list shown * is the active company's (same as the status endpoint reports). That * asymmetry lives in the group help, not in the row copy. */ export function ConnectorSettingsSection() { const t = useTranslations('settings_billing') const errorLocale = useLocale() as ErrorLocale const { toast } = useToast() const [status, setStatus] = useState(null) const [loadFailed, setLoadFailed] = useState(false) const [syncing, setSyncing] = useState(false) const [reloadKey, setReloadKey] = useState(0) const selfHosted = isSelfHosted() useEffect(() => { if (!selfHosted) return let active = true setLoadFailed(false) fetch('/api/connector/status') .then(async (res) => { if (!res.ok) throw new Error(`status ${res.status}`) const body = (await res.json()) as { data?: { self_hosted?: boolean } & ConnectorStatus } if (!active) return if (!body.data || body.data.self_hosted !== true) { setStatus(null) return } setStatus(body.data) }) .catch(() => { if (active) setLoadFailed(true) }) return () => { active = false } }, [selfHosted, reloadKey]) const runSync = useCallback(async () => { setSyncing(true) try { const res = await fetch('/api/connector/sync', { method: 'POST' }) const body = (await res.json().catch(() => null)) as | { data?: SyncResult; error?: unknown } | null if (!res.ok || !body?.data) { toast({ title: t('connector_sync_failed'), description: getErrorMessage(body, { statusCode: res.status, locale: errorLocale }), variant: 'destructive', }) return } const result = body.data if (result.outcome === 'synced') { // Count capabilities, not grant rows: grantsUpserted is // companies x scopes and reads absurd on a multi-company instance. toast({ title: t('connector_sync_done'), description: (result.scopes?.length ?? 0) > 0 ? t('connector_sync_done_desc', { count: result.scopes?.length ?? 0 }) : t('connector_sync_done_empty'), }) } else if (result.outcome === 'not_configured') { // The key vanished after the page loaded (operator reconfigured): // saying the hosted service was unreachable would be false. toast({ title: t('connector_status_unconfigured'), description: t('connector_status_unconfigured_note'), variant: 'destructive', }) } else if (result.outcome === 'revoked') { // The grants were just deleted: this must not read as a generic error. toast({ title: t('connector_sync_failed'), description: t('connector_sync_revoked_desc'), variant: 'destructive', }) } else { toast({ title: t('connector_sync_failed'), description: t('connector_sync_unreachable_desc'), variant: 'destructive', }) } setReloadKey((k) => k + 1) } catch { // fetch itself rejected (instance restarting, network drop): without // this the click is a silent no-op and the rejection goes unhandled. toast({ title: t('connector_sync_failed'), description: t('connector_sync_request_failed_desc'), variant: 'destructive', }) } finally { setSyncing(false) } }, [t, errorLocale, toast]) if (!selfHosted) return null if (!status && !loadFailed) return null const modeLabel: Record = { connector: t('connector_mode_connector'), own_credentials: t('connector_mode_own'), unconfigured: t('connector_mode_unconfigured'), } return ( {loadFailed || !status ? ( {t('connector_load_failed')} ) : ( <> {status.configured ? ( <> {t('connector_status_configured')} {status.key_prefix ? ( {status.key_prefix}… ) : null} ) : ( <> {t('connector_status_unconfigured')} {t('connector_status_unconfigured_note')} )} {status.configured ? ( ) : null} {modeLabel[status.upstreams.bank]} {modeLabel[status.upstreams.skatteverket]} {status.granted_capabilities.length > 0 ? ( {status.granted_capabilities.join(', ')} ) : ( {status.configured ? t('connector_caps_none') : t('connector_caps_unconfigured')} )} )} ) }