From b9a2ce522b33ecb09b38aa8b60a2b7860a69e121 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Wed, 6 May 2026 13:43:06 +0200 Subject: [PATCH] chore: post-bankid redirect, recapt feedback, TIC SPAR enrichment (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: post-bankid redirect, recapt feedback, TIC SPAR enrichment - BankID login + register now redirect to /select-company so the picker shows freshly enriched CompanyRoles from the current session. - New lib/support/submit-feedback util prefers window.recapt feedback widget when present, falls back to /api/support/contact. SupportLink uses it and hides itself in sandbox companies via new isSandbox flag on CompanyContext (+ useCompanyOptional hook). - TIC enrichment re-requests SPAR alongside CompanyRoles now that both types are enabled on the tenant; enrichment shape logged PII-free (booleans/counts only). Tests cover the SPAR+CompanyRoles path. - Skatteverket api-client: 15s AbortSignal timeout on outbound requests. - Swedish compliance review CI: bump REVIEW_MODEL to claude-opus-4-7. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: keep compliance review model on sonnet-4-6 Reverts the opus-4-7 bump from the previous commit per request. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(tic): don't persist SPAR PII to extension_data The previous commit started requesting SPAR alongside CompanyRoles and wrote the full enrichment payload (incl. personnummer, full name, home address, birth date, gender) verbatim to extension_data.value — a plain JSON column. Personnummer is already hashed + encrypted in bankid_identities, so the extension_data row was an unencrypted PII duplicate exposed to anyone with read access to the table. No consumer (middleware, /select-company, createCompanyFromTicRole) reads any SPAR field today; they only read companyRoles. Persist a sanitized blob of { companyRoles, enrichedAtUtc } instead. SPAR is still requested from TIC (and its shape logged PII-free) so enrichment completes; if address pre-fill ships later, those fields should be encrypted before storage. Also drop the dead `null` branch from SubmitFeedbackResult.channel — every code path returns 'recapt' or 'email'. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/(auth)/login/page.tsx | 4 +- app/(auth)/register/page.tsx | 2 +- app/(dashboard)/layout.tsx | 11 +- components/ui/support-link.tsx | 34 ++---- contexts/CompanyContext.tsx | 5 + .../general/skatteverket/lib/api-client.ts | 1 + .../tic/__tests__/bankid-complete.test.ts | 98 ++++++++++++++++- extensions/general/tic/index.ts | 56 ++++++---- lib/support/__tests__/submit-feedback.test.ts | 104 ++++++++++++++++++ lib/support/submit-feedback.ts | 48 ++++++++ types/recapt.d.ts | 11 ++ 11 files changed, 321 insertions(+), 53 deletions(-) create mode 100644 lib/support/__tests__/submit-feedback.test.ts create mode 100644 lib/support/submit-feedback.ts create mode 100644 types/recapt.d.ts diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 22214df5..5cc9fbc6 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -107,7 +107,9 @@ export default function LoginPage() { document.cookie = 'gnubok-invite-token=; path=/; max-age=0' } - router.push('/') + // Always land on the picker after BankID login so the user sees + // fresh CompanyRoles fetched during this session's enrichment. + router.push('/select-company') router.refresh() } catch (error) { console.error('[login] BankID complete error', error) diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 09125f15..0e41c940 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -144,7 +144,7 @@ function RegisterPageContent() { return } - router.push('/') + router.push('/select-company') router.refresh() } catch (error) { console.error('[register] BankID signup error', error) diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index f9d2a1f7..448647ed 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -81,6 +81,7 @@ export default async function DashboardLayout({ companies: [], isTeamMember, team, + isSandbox: false, }} > @@ -130,6 +131,7 @@ export default async function DashboardLayout({ })), isTeamMember, team, + isSandbox: false, } return ( @@ -179,6 +181,10 @@ export default async function DashboardLayout({ const displayName = settings?.company_name || companyRow.name const companyWithName = { ...companyRow, name: displayName } + const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' + + const isSandbox = settings?.is_sandbox === true + const companyContextValue = { company: companyWithName, role: memberRow.role as CompanyRole, @@ -192,12 +198,9 @@ export default async function DashboardLayout({ }), isTeamMember, team, + isSandbox, } - const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' - - const isSandbox = settings?.is_sandbox === true - return ( diff --git a/components/ui/support-link.tsx b/components/ui/support-link.tsx index 05021902..f1b1a235 100644 --- a/components/ui/support-link.tsx +++ b/components/ui/support-link.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState } from 'react' import { cn } from '@/lib/utils' import { Mail, Loader2, Send } from 'lucide-react' import { @@ -15,6 +15,8 @@ import { import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { useToast } from '@/components/ui/use-toast' +import { submitFeedback } from '@/lib/support/submit-feedback' +import { useCompanyOptional } from '@/contexts/CompanyContext' interface SupportLinkProps { variant?: 'inline' | 'muted' @@ -29,48 +31,36 @@ export function SupportLink({ children, className, }: SupportLinkProps) { - const [mounted, setMounted] = useState(false) const [open, setOpen] = useState(false) const [message, setMessage] = useState('') const [isSending, setIsSending] = useState(false) const [sent, setSent] = useState(false) const { toast } = useToast() + const companyCtx = useCompanyOptional() - useEffect(() => { - setMounted(true) - }, []) + if (companyCtx?.isSandbox) return null async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (message.trim().length < 5) return setIsSending(true) - try { - const res = await fetch('/api/support/contact', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ subject, message: message.trim() }), - }) - - if (!res.ok) { - const data = await res.json() - throw new Error(data.error || 'Kunde inte skicka meddelandet') - } + const result = await submitFeedback({ subject, message: message.trim() }) + setIsSending(false) + if (result.ok) { setSent(true) setTimeout(() => { setOpen(false) setSent(false) setMessage('') }, 2000) - } catch (error) { + } else { toast({ title: 'Kunde inte skicka', - description: error instanceof Error ? error.message : 'Försök igen.', + description: result.error || 'Försök igen.', variant: 'destructive', }) - } finally { - setIsSending(false) } } @@ -106,10 +96,6 @@ export function SupportLink({ ) - if (!mounted) { - return trigger - } - return ( {trigger} diff --git a/contexts/CompanyContext.tsx b/contexts/CompanyContext.tsx index c84249b4..ed589862 100644 --- a/contexts/CompanyContext.tsx +++ b/contexts/CompanyContext.tsx @@ -9,6 +9,7 @@ interface CompanyContextValue { companies: { company: Company; role: CompanyRole }[] isTeamMember: boolean team: Team | null + isSandbox: boolean } const CompanyContext = createContext(null) @@ -28,3 +29,7 @@ export function useCompany() { if (!ctx) throw new Error('useCompany must be used within CompanyProvider') return ctx } + +export function useCompanyOptional() { + return useContext(CompanyContext) +} diff --git a/extensions/general/skatteverket/lib/api-client.ts b/extensions/general/skatteverket/lib/api-client.ts index 3b34fa99..286a5e95 100644 --- a/extensions/general/skatteverket/lib/api-client.ts +++ b/extensions/general/skatteverket/lib/api-client.ts @@ -172,6 +172,7 @@ export async function skvRequest( method, headers, body: serializedBody, + signal: AbortSignal.timeout(15_000), }) // Handle Skatteverket-specific auth/throttle errors uniformly so callers diff --git a/extensions/general/tic/__tests__/bankid-complete.test.ts b/extensions/general/tic/__tests__/bankid-complete.test.ts index 3dda2d0f..e78e4ae9 100644 --- a/extensions/general/tic/__tests__/bankid-complete.test.ts +++ b/extensions/general/tic/__tests__/bankid-complete.test.ts @@ -15,7 +15,7 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(), })) -import { collectBankIdResult } from '../lib/bankid-client' +import { collectBankIdResult, requestEnrichment, fetchEnrichmentData } from '../lib/bankid-client' import { createServiceClient } from '@/lib/supabase/server' import { ticExtension } from '../index' @@ -208,6 +208,102 @@ describe('POST /bankid/complete', () => { }) }) + describe('enrichment — SPAR + CompanyRoles', () => { + it('requests both SPAR and CompanyRoles, fetches data, and persists only companyRoles (no PII) to extension_data', async () => { + vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) + vi.mocked(requestEnrichment).mockResolvedValueOnce({ + enrichmentId: 'enr-1', + sessionId: 'test-session', + status: 'Completed', + requestedTypes: ['SPAR', 'CompanyRoles'], + completedTypes: ['SPAR', 'CompanyRoles'], + secureUrl: '/api/v1/enrichment/data/abc', + secureUrlExpiresAtUtc: '2026-05-06T12:00:00Z', + }) + vi.mocked(fetchEnrichmentData).mockResolvedValueOnce({ + personalNumber: '199001011234', + name: 'Anna Andersson', + enrichedAtUtc: '2026-05-06T11:30:00Z', + spar: { + Person_IdNummer: '199001011234', + Person_PersonIdTyp: 'PERSONNR', + Skydd_Sekretessmarkering: false, + Skydd_SkyddadFolkbokforing: false, + Namn_Fornamn: 'Anna', + Namn_Efternamn: 'Andersson', + PersonDetaljer_Kon: 'K', + PersonDetaljer_Fodelsedatum: '1990-01-01', + Folkbokforingsadress_SvenskAdress_Utdelningsadress1: 'Storgatan 1', + Folkbokforingsadress_SvenskAdress_PostNr: '11122', + Folkbokforingsadress_SvenskAdress_Postort: 'Stockholm', + }, + companyRoles: [ + { + companyId: 12345, + companyRegistrationNumber: '5566778899', + legalName: 'Exempel AB', + legalEntityType: 'AB', + positionTypes: ['LED'], + positionDescriptions: ['Styrelseledamot'], + positionStart: '2020-01-15', + positionEnd: null, + companyStatus: 'Aktivt', + }, + ], + }) + const { client } = mockServiceClient([ + { data: null }, // pnr lookup → not linked + { data: null }, // email lookup → not taken + { error: null }, // bankid_identities insert OK + ]) + + // Intercept the extension_data upsert so we can assert the persisted shape + // contains no SPAR / personnummer / name. Other tables fall through to the + // queued chain. + const upsertSpy = vi.fn().mockResolvedValue({ error: null }) + const origFrom = client.from as unknown as ReturnType + const queuedFrom = origFrom.getMockImplementation() as (table: string) => unknown + origFrom.mockImplementation((table: string) => { + if (table === 'extension_data') { + return { upsert: upsertSpy } + } + return queuedFrom(table) + }) + + const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { + method: 'POST', + body: { sessionId: 'test-session', mode: 'signup', email: 'fresh@example.com' }, + }) + const { status, body } = await parseJsonResponse<{ + data?: { tokenHash?: string; isNewUser?: boolean } + }>(await findCompleteHandler()(req)) + + expect(status).toBe(200) + expect(body.data?.isNewUser).toBe(true) + expect(vi.mocked(requestEnrichment)).toHaveBeenCalledWith( + 'test-session', + ['SPAR', 'CompanyRoles'] + ) + expect(vi.mocked(fetchEnrichmentData)).toHaveBeenCalledWith('/api/v1/enrichment/data/abc') + + // Persisted blob must contain companyRoles + enrichedAtUtc only. + // SPAR (personnummer / name / address / birth date) must NOT be stored, + // even when TIC returns it — those fields live in bankid_identities (encrypted). + expect(upsertSpy).toHaveBeenCalledTimes(1) + const [persistedRow] = upsertSpy.mock.calls[0] as [ + { key: string; value: Record }, + ] + expect(persistedRow.key).toBe('bankid_enrichment') + expect(persistedRow.value).toEqual({ + companyRoles: expect.any(Array), + enrichedAtUtc: '2026-05-06T11:30:00Z', + }) + expect(persistedRow.value).not.toHaveProperty('spar') + expect(persistedRow.value).not.toHaveProperty('personalNumber') + expect(persistedRow.value).not.toHaveProperty('name') + }) + }) + describe('input validation', () => { it('returns 400 session_invalid when BankID session is not complete', async () => { vi.mocked(collectBankIdResult).mockResolvedValue( diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts index d23110ea..bef60193 100644 --- a/extensions/general/tic/index.ts +++ b/extensions/general/tic/index.ts @@ -30,14 +30,20 @@ import crypto from 'crypto' const log = createLogger('tic/bankid') /** - * Request CompanyRoles enrichment for a completed BankID session and cache - * the result in `extension_data` so /select-company can pre-fill the picker. + * Request SPAR + CompanyRoles enrichment for a completed BankID session and + * cache the CompanyRoles slice in `extension_data` for the + * /select-company picker. + * + * SPAR (personnummer, address, name, birth date) is requested so TIC will + * complete the enrichment, but is intentionally NOT persisted: personnummer + * is already hashed + encrypted in `bankid_identities`, names live there too, + * and no UI currently consumes the address. Storing the SPAR blob in + * `extension_data.value` (a plain JSON column) would expose national-ID-level + * PII to anyone with read access. If/when address pre-fill is built, encrypt + * the relevant fields the same way `encryptPersonalNumber` does for pnr. + * * Non-blocking: any failure is logged and swallowed — BankID auth must still * succeed even if enrichment is down. - * - * Only types currently enabled on the TIC tenant are requested — see the - * block comment inside the function. If Address (formerly SPAR) is enabled - * later, add it here to restore address pre-fill in the manual wizard. */ async function fetchAndStoreEnrichment( sessionId: string, @@ -45,19 +51,15 @@ async function fetchAndStoreEnrichment( supabase: SupabaseClient, ): Promise { try { - // IMPORTANT: only request types that are actually enabled on the TIC - // tenant. Requesting an unknown/disabled type (e.g. 'SPAR', which TIC - // has renamed to 'Address' and which our tenant currently has off) - // makes TIC reject the whole enrichment with - // `error: 'Session not completed'` — a misleading error that took a - // round of debugging to trace. Verified via GET /api/v1/enrichment/types: - // { type: 'CompanyRoles', enabled: true } ← we want this - // { type: 'Address', enabled: false } ← formerly SPAR, off + // Both 'SPAR' and 'CompanyRoles' are enabled on our TIC tenant as of + // 2026-05-06 (TIC ticket re. enrichment). Verify with: + // curl -H "X-Api-Key: $KEY" https://id.tic.io/api/v1/enrichment/types // - // If 'Address' gets enabled later, add it here (and wire up the - // address pre-fill in WelcomeOnboarding and createCompanyFromTicRole - // — both already look for a `.spar` field that TIC may have renamed). - const enrichment = await requestEnrichment(sessionId, ['CompanyRoles']) + // If a requested type is disabled on the tenant, TIC rejects the WHOLE + // enrichment with body field `error: 'Session not completed'` (HTTP 200, + // not a real HTTP error). The message is misleading — it does NOT mean + // the BankID session is incomplete. The hint mapping below catches it. + const enrichment = await requestEnrichment(sessionId, ['SPAR', 'CompanyRoles']) log.info('enrichment request returned', { status: enrichment.status, requestedTypes: enrichment.requestedTypes, @@ -102,10 +104,10 @@ async function fetchAndStoreEnrichment( const enrichmentData = await fetchEnrichmentData(enrichment.secureUrl) // Log a PII-free snapshot so we can debug the role filter in production. - // Raw personnummer/names are deliberately omitted. `spar`/`address` not - // logged — we don't request those types currently (see block comment - // on requestEnrichment above), so they'd always be absent. + // Raw personnummer / names / address values are deliberately omitted — + // only flat booleans and counts. const firstRole = enrichmentData.companyRoles?.[0] + const spar = enrichmentData.spar log.info('enrichment data shape', { companyCount: enrichmentData.companyRoles?.length ?? 0, firstRoleStatuses: firstRole @@ -116,15 +118,25 @@ async function fetchAndStoreEnrichment( legalEntityType: firstRole.legalEntityType, } : null, + hasSpar: !!spar, + sparHasAddress: !!spar?.Folkbokforingsadress_SvenskAdress_Utdelningsadress1, + sparHasProtection: !!(spar?.Skydd_Sekretessmarkering || spar?.Skydd_SkyddadFolkbokforing), }) + // Persist only what consumers actually read. See block comment on + // fetchAndStoreEnrichment for why SPAR + personnummer + name are excluded. + const persistedValue = { + companyRoles: enrichmentData.companyRoles ?? [], + enrichedAtUtc: enrichmentData.enrichedAtUtc, + } + await supabase .from('extension_data') .upsert({ user_id: userId, extension_id: 'tic', key: 'bankid_enrichment', - value: enrichmentData, + value: persistedValue, }, { onConflict: 'user_id,extension_id,key' }) } catch (enrichError) { log.warn('enrichment failed (non-blocking)', enrichError) diff --git a/lib/support/__tests__/submit-feedback.test.ts b/lib/support/__tests__/submit-feedback.test.ts new file mode 100644 index 00000000..d7b1feff --- /dev/null +++ b/lib/support/__tests__/submit-feedback.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { submitFeedback } from '@/lib/support/submit-feedback' + +describe('submitFeedback', () => { + beforeEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + function stubRecapt(impl: (...args: unknown[]) => void) { + vi.stubGlobal('window', { recapt: impl }) + } + + function stubNoRecapt() { + vi.stubGlobal('window', {}) + } + + it('uses Recapt when SDK is present and prepends subject', async () => { + const recapt = vi.fn() + stubRecapt(recapt) + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + + const result = await submitFeedback({ subject: 'Hjälpsida', message: 'Hjälp tack' }) + + expect(result).toEqual({ ok: true, channel: 'recapt' }) + expect(recapt).toHaveBeenCalledWith('feedback', { message: '[Hjälpsida]\n\nHjälp tack' }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('uses Recapt without subject prefix when subject omitted', async () => { + const recapt = vi.fn() + stubRecapt(recapt) + + await submitFeedback({ message: 'plain' }) + + expect(recapt).toHaveBeenCalledWith('feedback', { message: 'plain' }) + }) + + it('falls back to email when Recapt throws', async () => { + stubRecapt(() => { + throw new Error('boom') + }) + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + }) + vi.stubGlobal('fetch', fetchSpy) + + const result = await submitFeedback({ subject: 'X', message: 'msg' }) + + expect(result).toEqual({ ok: true, channel: 'email' }) + expect(fetchSpy).toHaveBeenCalledWith( + '/api/support/contact', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ subject: 'X', message: 'msg' }), + }) + ) + }) + + it('falls back to email when Recapt SDK is absent', async () => { + stubNoRecapt() + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + }) + vi.stubGlobal('fetch', fetchSpy) + + const result = await submitFeedback({ message: 'msg' }) + + expect(result).toEqual({ ok: true, channel: 'email' }) + expect(fetchSpy).toHaveBeenCalledOnce() + }) + + it('returns failure with error from email when fetch returns non-ok', async () => { + stubNoRecapt() + const fetchSpy = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ error: 'Mailtjänsten är inte konfigurerad' }), + }) + vi.stubGlobal('fetch', fetchSpy) + + const result = await submitFeedback({ message: 'msg' }) + + expect(result.ok).toBe(false) + expect(result.channel).toBe('email') + expect(result.error).toBe('Mailtjänsten är inte konfigurerad') + }) + + it('returns failure when fetch itself throws', async () => { + stubNoRecapt() + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network down'))) + + const result = await submitFeedback({ message: 'msg' }) + + expect(result.ok).toBe(false) + expect(result.error).toBe('Network down') + }) +}) diff --git a/lib/support/submit-feedback.ts b/lib/support/submit-feedback.ts new file mode 100644 index 00000000..d11ea390 --- /dev/null +++ b/lib/support/submit-feedback.ts @@ -0,0 +1,48 @@ +export interface SubmitFeedbackInput { + message: string + subject?: string +} + +export interface SubmitFeedbackResult { + ok: boolean + channel: 'recapt' | 'email' + error?: string +} + +function composeMessage({ message, subject }: SubmitFeedbackInput): string { + if (!subject) return message + return `[${subject}]\n\n${message}` +} + +async function submitViaEmail({ message, subject }: SubmitFeedbackInput): Promise { + try { + const res = await fetch('/api/support/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subject, message }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + return { ok: false, channel: 'email', error: data.error || 'Kunde inte skicka meddelandet' } + } + return { ok: true, channel: 'email' } + } catch (err) { + return { ok: false, channel: 'email', error: err instanceof Error ? err.message : 'Nätverksfel' } + } +} + +export async function submitFeedback(input: SubmitFeedbackInput): Promise { + const recapt = typeof window !== 'undefined' ? window.recapt : undefined + const fullMessage = composeMessage(input) + + if (typeof recapt === 'function') { + try { + recapt('feedback', { message: fullMessage }) + return { ok: true, channel: 'recapt' } + } catch { + // fall through to email + } + } + + return submitViaEmail(input) +} diff --git a/types/recapt.d.ts b/types/recapt.d.ts new file mode 100644 index 00000000..9d96766f --- /dev/null +++ b/types/recapt.d.ts @@ -0,0 +1,11 @@ +type RecaptFeedbackPayload = + | { message: string; rating?: number } + | { widget: 'show' | 'hide' | 'open' | 'close'; position?: string } + +declare global { + interface Window { + recapt?: (action: 'feedback', data: RecaptFeedbackPayload) => void + } +} + +export {}