From ee22c9c7b75dd8691ab0ca683505ef3ec549482e Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:44:33 +0200 Subject: [PATCH] feat(connect): connector status + Synka nu row in Settings -> Abonnemang (PR6b-3) (#2104) * feat(connect): connector status + Synka nu row in Settings -> Abonnemang (PR6b-3) Self-host only: shows per-upstream connector mode, key prefix, and the active company's granted capabilities from GET /api/connector/status, plus a manual run of the entitlement sync via the new authed POST /api/connector/sync (requireWrite, 60s cooldown) instead of waiting for the hourly cron. Hidden on hosted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KAUKbGUGtAmRhpDKfn84F5 * fix(connect): handle sync fetch rejection, count capabilities not rows, not_configured toast Skeptic findings on the Synka nu flow: a rejected fetch (instance restarting) was a silent no-op with an unhandled rejection; the success toast printed grant rows (companies x scopes) as capabilities; a not_configured outcome claimed the hosted service was unreachable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KAUKbGUGtAmRhpDKfn84F5 --------- Co-authored-by: Claude Fable 5 --- .../connector/sync/__tests__/route.test.ts | 91 ++++++++ app/api/connector/sync/route.ts | 55 +++++ .../settings/ConnectorSettingsSection.tsx | 212 ++++++++++++++++++ .../sections/BillingSettingsContent.tsx | 13 ++ lib/connect/instance/manual-sync-throttle.ts | 30 +++ messages/en.json | 26 ++- messages/sv.json | 26 ++- 7 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 app/api/connector/sync/__tests__/route.test.ts create mode 100644 app/api/connector/sync/route.ts create mode 100644 components/settings/ConnectorSettingsSection.tsx create mode 100644 lib/connect/instance/manual-sync-throttle.ts diff --git a/app/api/connector/sync/__tests__/route.test.ts b/app/api/connector/sync/__tests__/route.test.ts new file mode 100644 index 00000000..205775f2 --- /dev/null +++ b/app/api/connector/sync/__tests__/route.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' +import { resetManualSyncThrottle } from '@/lib/connect/instance/manual-sync-throttle' + +let selfHosted = true +vi.mock('@/lib/env/public-flags', () => ({ isSelfHosted: () => selfHosted })) + +const captured = vi.hoisted(() => ({ options: undefined as { requireWrite?: boolean } | undefined })) +vi.mock('@/lib/api/with-route-context', () => ({ + withRouteContext: ( + _op: string, + handler: (req: unknown, ctx: unknown) => unknown, + options?: { requireWrite?: boolean }, + ) => { + captured.options = options + return (req: unknown) => + handler(req, { + supabase: {}, + companyId: 'company-1', + user: { id: 'u1' }, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }) + }, +})) + +const syncMock = vi.fn() +vi.mock('@/lib/connect/instance/sync', () => ({ + syncConnectorEntitlements: (...args: unknown[]) => syncMock(...args), +})) +vi.mock('@/lib/supabase/server', () => ({ createServiceClient: () => ({ __service: true }) })) + +import { POST } from '../route' + +const run = () => POST(createMockRequest('/api/connector/sync', { method: 'POST' }), { params: Promise.resolve({}) }) + +beforeEach(() => { + vi.clearAllMocks() + resetManualSyncThrottle() + selfHosted = true + vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_secretsecret') + syncMock.mockResolvedValue({ outcome: 'synced', companies: 2, grantsUpserted: 4, grantsDeleted: 0, scopes: ['bank_sync'] }) +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('POST /api/connector/sync', () => { + it('requires a non-viewer role', () => { + expect(captured.options).toEqual({ requireWrite: true }) + }) + + it('answers not_configured on hosted without running the sync', async () => { + selfHosted = false + const { status, body } = await parseJsonResponse<{ data: { outcome: string } }>(await run()) + expect(status).toBe(200) + expect(body.data.outcome).toBe('not_configured') + expect(syncMock).not.toHaveBeenCalled() + }) + + it('answers not_configured when no connector key is set', async () => { + vi.stubEnv('GNUBOK_CONNECTOR_KEY', '') + const { body } = await parseJsonResponse<{ data: { outcome: string } }>(await run()) + expect(body.data.outcome).toBe('not_configured') + expect(syncMock).not.toHaveBeenCalled() + }) + + it('runs the entitlement sync with the service client and returns the result', async () => { + const { status, body } = await parseJsonResponse<{ data: { outcome: string; grantsUpserted: number } }>(await run()) + expect(status).toBe(200) + expect(body.data.outcome).toBe('synced') + expect(body.data.grantsUpserted).toBe(4) + expect(syncMock).toHaveBeenCalledTimes(1) + expect(syncMock.mock.calls[0][0]).toEqual({ __service: true }) + expect(Object.keys(syncMock.mock.calls[0][1] as object)).toEqual( + expect.arrayContaining(['instanceUrl', 'appVersion']), + ) + }) + + it('refuses a second run inside the cooldown window', async () => { + await run() + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await run()) + expect(status).toBe(429) + expect(body.error.code).toBe('CONNECTOR_SYNC_COOLDOWN') + expect(syncMock).toHaveBeenCalledTimes(1) + }) + + it('keeps the cooldown even when the sync throws', async () => { + syncMock.mockRejectedValueOnce(new Error('db down')) + await expect(run()).rejects.toThrow('db down') + const { status } = await parseJsonResponse<{ error: { code: string } }>(await run()) + expect(status).toBe(429) + }) +}) diff --git a/app/api/connector/sync/route.ts b/app/api/connector/sync/route.ts new file mode 100644 index 00000000..d6475ad3 --- /dev/null +++ b/app/api/connector/sync/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { isSelfHosted } from '@/lib/env/public-flags' +import { createServiceClient } from '@/lib/supabase/server' +import { getConnectorConfig } from '@/lib/connect/instance/config' +import { syncConnectorEntitlements } from '@/lib/connect/instance/sync' +import { + endManualSync, + tryBeginManualSync, +} from '@/lib/connect/instance/manual-sync-throttle' + +/** + * POST /api/connector/sync: operator-triggered run of the same entitlement + * sync the hourly self-hosted cron performs (/api/connector/sync/cron), + * for the "Synka nu" row in Settings -> Abonnemang. Runs with the service + * client because grants are instance-wide (every company on the instance), + * so the caller must hold a non-viewer role (requireWrite) and runs are + * cooldown-gated: each one reports to the hosted entitlements endpoint. + * + * Hosted (or an instance without a key) answers 200 not_configured rather + * than an error: the settings row is hidden there and this is the backstop. + */ +export const maxDuration = 60 + +export const POST = withRouteContext( + 'connector.sync', + async (_request, { log }) => { + if (!isSelfHosted() || !getConnectorConfig()) { + return NextResponse.json({ data: { outcome: 'not_configured' } }) + } + if (!tryBeginManualSync()) { + return NextResponse.json( + { + error: { + code: 'CONNECTOR_SYNC_COOLDOWN', + message: 'En synkronisering kördes nyss. Vänta en minut och försök igen.', + message_en: 'A sync just ran. Wait a minute and try again.', + }, + }, + { status: 429, headers: { 'Retry-After': '60' } }, + ) + } + try { + const result = await syncConnectorEntitlements(createServiceClient(), { + instanceUrl: process.env.NEXT_PUBLIC_APP_URL?.trim() || null, + appVersion: process.env.npm_package_version ?? null, + }) + log.info('manual connector sync run', { ...result }) + return NextResponse.json({ data: result }) + } finally { + endManualSync() + } + }, + { requireWrite: true }, +) diff --git a/components/settings/ConnectorSettingsSection.tsx b/components/settings/ConnectorSettingsSection.tsx new file mode 100644 index 00000000..2efd627e --- /dev/null +++ b/components/settings/ConnectorSettingsSection.tsx @@ -0,0 +1,212 @@ +'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')} + + )} + + + )} + + ) +} diff --git a/components/settings/sections/BillingSettingsContent.tsx b/components/settings/sections/BillingSettingsContent.tsx index f591bf18..ad59ee20 100644 --- a/components/settings/sections/BillingSettingsContent.tsx +++ b/components/settings/sections/BillingSettingsContent.tsx @@ -18,6 +18,7 @@ import { import { cn, formatCurrency } from '@/lib/utils' import { useFormat } from '@/lib/hooks/use-format' import { BillingActions } from '@/components/settings/BillingActions' +import { ConnectorSettingsSection } from '@/components/settings/ConnectorSettingsSection' import { PLAN_PRICES } from '@/components/settings/billing-plans' import type { BillingPlan } from '@/lib/stripe/client' import { useBranding } from '@/lib/branding/brand-context' @@ -74,6 +75,18 @@ function UnlockList({ className }: { className?: string }) { * made, instead of repeated as reassurance copy around the page. */ export function BillingSettingsContent() { + return ( + <> + + {/* Self-host only (renders null on hosted): connector status + manual + entitlement sync. Sits below the billing states, which is why the + core content is split out: it has several early returns. */} + + + ) +} + +function BillingCoreContent() { const tNav = useTranslations('settings_nav') const tIntro = useTranslations('settings_intro') const t = useTranslations('settings_billing') diff --git a/lib/connect/instance/manual-sync-throttle.ts b/lib/connect/instance/manual-sync-throttle.ts new file mode 100644 index 00000000..22e0f414 --- /dev/null +++ b/lib/connect/instance/manual-sync-throttle.ts @@ -0,0 +1,30 @@ +/** + * Cooldown for the operator-triggered connector sync (POST /api/connector/sync). + * Every run POSTs the instance's company count to the hosted entitlements + * endpoint, so a click storm in the settings panel must not turn into a + * request storm against the hosted service. Module-level state is enough: + * self-hosted runs as one long-lived process, and the hourly cron is + * unaffected (it never goes through this gate). + */ +export const MANUAL_SYNC_COOLDOWN_MS = 60_000 + +let inFlight = false +let lastStartedAt = 0 + +/** Claims the sync slot; false while a sync runs or the cooldown holds. */ +export function tryBeginManualSync(now: number = Date.now()): boolean { + if (inFlight || now - lastStartedAt < MANUAL_SYNC_COOLDOWN_MS) return false + inFlight = true + lastStartedAt = now + return true +} + +export function endManualSync(): void { + inFlight = false +} + +/** Test hook: clears the in-flight flag and the cooldown window. */ +export function resetManualSyncThrottle(): void { + inFlight = false + lastStartedAt = 0 +} diff --git a/messages/en.json b/messages/en.json index f8ccc6ca..e1d2fc9d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2274,7 +2274,31 @@ "cta_coming_soon": "Upgrades open soon", "cta_opening": "Opening…", "cta_start_deferred": "Start the subscription: 0 kr today", - "cta_start_now": "Start the subscription: {price}/{period}" + "cta_start_now": "Start the subscription: {price}/{period}", + "connector_group": "Connector", + "connector_help": "The connector links your self-hosted instance to the hosted service for bank and Skatteverket. Syncing refreshes the capabilities for every company on the instance; the list below shows the active company's. Left alone, they sync automatically every hour.", + "connector_row_status": "Status", + "connector_status_configured": "Connected", + "connector_status_unconfigured": "No connector key", + "connector_status_unconfigured_note": "Set GNUBOK_CONNECTOR_KEY in the environment and restart the instance.", + "connector_row_bank": "Bank", + "connector_row_skv": "Skatteverket", + "connector_mode_connector": "Via connector", + "connector_mode_own": "Own credentials", + "connector_mode_unconfigured": "Not configured", + "connector_row_capabilities": "Capabilities", + "connector_caps_none": "No active capabilities yet. Sync to fetch them.", + "connector_caps_unconfigured": "Activates once a connector key is configured.", + "connector_sync_button": "Sync now", + "connector_sync_button_busy": "Syncing", + "connector_sync_done": "Sync complete", + "connector_sync_done_desc": "{count, plural, =1 {1 capability updated} other {# capabilities updated}}.", + "connector_sync_done_empty": "The key is valid but the subscription grants no capabilities right now.", + "connector_sync_failed": "Sync failed", + "connector_sync_revoked_desc": "The key was rejected by the hosted service and the connector capabilities were removed. Check the subscription or the key.", + "connector_sync_unreachable_desc": "The hosted service could not be reached. Existing capabilities are kept and remain valid until they expire.", + "connector_sync_request_failed_desc": "The request failed. Check that the instance is running and try again.", + "connector_load_failed": "Could not read connector status." }, "settings_invoice_form": { "default_our_reference_label": "Default \"Our reference\"", diff --git a/messages/sv.json b/messages/sv.json index 6f8b7756..d76ca465 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2274,7 +2274,31 @@ "cta_coming_soon": "Uppgradering öppnar snart", "cta_opening": "Öppnar…", "cta_start_deferred": "Starta abonnemanget: 0 kr idag", - "cta_start_now": "Starta abonnemanget: {price}/{period}" + "cta_start_now": "Starta abonnemanget: {price}/{period}", + "connector_group": "Connector", + "connector_help": "Connectorn kopplar din självhostade instans till värdtjänsten för bank och Skatteverket. Synkroniseringen uppdaterar behörigheterna för alla företag på instansen; listan nedan visar det aktiva företagets. Utan åtgärd synkas de automatiskt varje timme.", + "connector_row_status": "Status", + "connector_status_configured": "Ansluten", + "connector_status_unconfigured": "Ingen connector-nyckel", + "connector_status_unconfigured_note": "Sätt GNUBOK_CONNECTOR_KEY i miljön och starta om instansen.", + "connector_row_bank": "Bank", + "connector_row_skv": "Skatteverket", + "connector_mode_connector": "Via connector", + "connector_mode_own": "Egna uppgifter", + "connector_mode_unconfigured": "Ej konfigurerad", + "connector_row_capabilities": "Behörigheter", + "connector_caps_none": "Inga aktiva behörigheter än. Synka för att hämta dem.", + "connector_caps_unconfigured": "Aktiveras när en connector-nyckel är konfigurerad.", + "connector_sync_button": "Synka nu", + "connector_sync_button_busy": "Synkar", + "connector_sync_done": "Synkroniseringen är klar", + "connector_sync_done_desc": "{count, plural, =1 {1 behörighet uppdaterad} other {# behörigheter uppdaterade}}.", + "connector_sync_done_empty": "Nyckeln är giltig men abonnemanget ger inga behörigheter just nu.", + "connector_sync_failed": "Synkroniseringen misslyckades", + "connector_sync_revoked_desc": "Nyckeln avvisades av värdtjänsten och connector-behörigheterna har tagits bort. Kontrollera abonnemanget eller nyckeln.", + "connector_sync_unreachable_desc": "Värdtjänsten kunde inte nås. Befintliga behörigheter behålls och gäller tills de löper ut.", + "connector_sync_request_failed_desc": "Begäran misslyckades. Kontrollera att instansen är igång och försök igen.", + "connector_load_failed": "Kunde inte läsa connector-status." }, "settings_invoice_form": { "default_our_reference_label": "Standard för Vår referens",