diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index e8280b5d..c6d48d8c 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -2,6 +2,7 @@ import { redirect } from 'next/navigation' import DashboardContent from '@/components/dashboard/DashboardContent' import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist' import { listResumeItems } from '@/lib/worklist/resume' +import { shouldShowOtherAccountHint } from '@/lib/company/other-account-hint' import type { OnboardingProgress } from '@/types' import { getDashboardAuthContext, @@ -48,6 +49,7 @@ export default async function DashboardPage() { worklist, suggestedMatches, resumeItems, + otherAccountHint, ] = await Promise.all([ getDashboardSettings(), supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId), @@ -68,6 +70,10 @@ export default async function DashboardPage() { listSuggestedMatches(supabase, companyId, 5), // In-progress work for the Fortsätt pane: pure draft-state derivation. listResumeItems(supabase, companyId, now), + // Wrong-account hint (#1231): true only when this account has zero + // journal entries while a same-orgnr company with real bookkeeping + // exists in another account. Common case costs one existence probe. + shouldShowOtherAccountHint(supabase), ]) // A FAILED settings read must not masquerade as "onboarding not done": @@ -122,6 +128,7 @@ export default async function DashboardPage() { worklist={worklist} suggestedMatches={suggestedMatches} resumeItems={resumeItems} + otherAccountHint={otherAccountHint} onboardingProgress={onboardingProgress} initialSetup={{ path: settings.initial_setup_path ?? null, diff --git a/app/api/company/check-org-number/__tests__/route.test.ts b/app/api/company/check-org-number/__tests__/route.test.ts index 212d8ccb..58ac9161 100644 --- a/app/api/company/check-org-number/__tests__/route.test.ts +++ b/app/api/company/check-org-number/__tests__/route.test.ts @@ -2,13 +2,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(), + createServiceClient: vi.fn(), })) -import { createClient } from '@/lib/supabase/server' +import { createClient, createServiceClient } from '@/lib/supabase/server' import { GET } from '../route' import { createMockRequest, parseJsonResponse } from '@/tests/helpers' const mockCreateClient = vi.mocked(createClient) +const mockCreateServiceClient = vi.mocked(createServiceClient) + +/** Service-client mock for the cross-account probe: one companies query. */ +function buildServiceClient(result: { data?: unknown; error?: unknown }) { + const resolved = { data: result.data ?? null, error: result.error ?? null } + const chain: Record = {} + for (const m of ['select', 'eq', 'is', 'limit']) { + chain[m] = () => chain + } + ;(chain as { then?: unknown }).then = (resolve: (v: unknown) => void) => resolve(resolved) + return { from: vi.fn(() => chain) } +} /** * Minimal authenticated-client mock. `companies.data` seeds what the RLS-scoped @@ -37,6 +50,8 @@ function buildSupabase(opts: { beforeEach(() => { vi.clearAllMocks() + // Default: nothing exists anywhere else. Individual tests override. + mockCreateServiceClient.mockReturnValue(buildServiceClient({ data: [] }) as never) }) describe('GET /api/company/check-org-number', () => { @@ -94,6 +109,52 @@ describe('GET /api/company/check-org-number', () => { expect(body.data.exists).toBe(false) }) + it('reports exists_elsewhere when the org number lives in another account', async () => { + mockCreateClient.mockResolvedValue( + buildSupabase({ user: { id: 'u1' }, companies: { data: [] } }) as never, + ) + mockCreateServiceClient.mockReturnValue( + buildServiceClient({ data: [{ id: 'other-account-co' }] }) as never, + ) + const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790')) + const { status, body } = await parseJsonResponse<{ + data: { exists: boolean; companies: unknown[]; exists_elsewhere: boolean } + }>(res) + expect(status).toBe(200) + expect(body.data.exists).toBe(false) + // Existence only: the other account's company is never listed. + expect(body.data.companies).toEqual([]) + expect(body.data.exists_elsewhere).toBe(true) + }) + + it('does not report exists_elsewhere when all matches are the caller’s own', async () => { + mockCreateClient.mockResolvedValue( + buildSupabase({ + user: { id: 'u1' }, + companies: { data: [{ id: 'c1', name: 'Acme AB' }] }, + }) as never, + ) + mockCreateServiceClient.mockReturnValue( + buildServiceClient({ data: [{ id: 'c1' }] }) as never, + ) + const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790')) + const { body } = await parseJsonResponse<{ data: { exists_elsewhere: boolean } }>(res) + expect(body.data.exists_elsewhere).toBe(false) + }) + + it('fails soft to exists_elsewhere:false when the probe errors', async () => { + mockCreateClient.mockResolvedValue( + buildSupabase({ user: { id: 'u1' }, companies: { data: [] } }) as never, + ) + mockCreateServiceClient.mockReturnValue( + buildServiceClient({ error: { message: 'probe boom' } }) as never, + ) + const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790')) + const { status, body } = await parseJsonResponse<{ data: { exists_elsewhere: boolean } }>(res) + expect(status).toBe(200) + expect(body.data.exists_elsewhere).toBe(false) + }) + it('returns 500 when the query errors', async () => { mockCreateClient.mockResolvedValue( buildSupabase({ user: { id: 'u1' }, companies: { error: { message: 'boom' } } }) as never, diff --git a/app/api/company/check-org-number/route.ts b/app/api/company/check-org-number/route.ts index d3bcc3c8..f627f27d 100644 --- a/app/api/company/check-org-number/route.ts +++ b/app/api/company/check-org-number/route.ts @@ -1,21 +1,27 @@ import { NextResponse } from 'next/server' import { requireAuth } from '@/lib/auth/require-auth' +import { createServiceClient } from '@/lib/supabase/server' import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' /** * GET /api/company/check-org-number?org_number=XXXXXXXXXX * - * Returns `{ data: { exists: boolean, companies: { id, name }[] } }` for the - * companies the CURRENT USER already has with the given organisation number: - * scoped to their own account only. + * Returns `{ data: { exists, companies, exists_elsewhere } }`: + * - `exists` / `companies`: matches among the CURRENT USER's own companies + * (`{ id, name }[]`), scoped by RLS. + * - `exists_elsewhere`: true when a non-archived company with this org number + * exists in an account the caller is NOT a member of. Existence only: no + * id, name, or owner ever leaves the server. This is what lets the wizard + * hint "this company already exists in Accounted" before a user rebuilds + * their bookkeeping in a second account and strands the first (#1231). * * Org-number reuse across the platform is intentionally allowed (see - * lib/company/actions.ts), so this is a soft, account-scoped warning, NOT a - * uniqueness gate. It uses the normal authenticated client on purpose: the - * `companies` SELECT RLS policy limits results to companies the caller is a - * member of (id IN user_company_ids()), so it can never reveal another user's - * companies and can't be used to enumerate org numbers platform-wide. + * lib/company/actions.ts), so both signals are soft warnings, NOT a + * uniqueness gate. The own-companies query uses the authenticated client on + * purpose: the `companies` SELECT RLS policy limits results to companies the + * caller is a member of (id IN user_company_ids()). The cross-account probe + * uses the service client but deliberately reduces to one boolean. * * Normalizes input with the same rule as the create action so a 12-digit form * still matches a stored 10-digit canonical. Returns no matches for malformed @@ -37,7 +43,7 @@ export async function GET(request: Request) { const canonical = normalizeOrgNumber(raw) if (!canonical) { // Malformed input is not a duplicate of anything by definition. - return NextResponse.json({ data: { exists: false, companies: [] } }) + return NextResponse.json({ data: { exists: false, companies: [], exists_elsewhere: false } }) } // RLS scopes this SELECT to the caller's own memberships (companies_select: @@ -56,7 +62,28 @@ export async function GET(request: Request) { id: c.id, name: c.name, })) + + // Cross-account probe (service role bypasses RLS): does any non-archived + // company with this org number exist outside the caller's memberships? + // Fails soft to false: this is advisory, never worth blocking the wizard. + let existsElsewhere = false + try { + const service = createServiceClient() + const ownIds = new Set(companies.map((c) => c.id)) + const { data: allMatches, error: probeError } = await service + .from('companies') + .select('id') + .eq('org_number', canonical) + .is('archived_at', null) + .limit(ownIds.size + 1) + if (!probeError) { + existsElsewhere = (allMatches ?? []).some((c: { id: string }) => !ownIds.has(c.id)) + } + } catch { + // Service key unavailable (some self-hosted setups): skip the hint. + } + return NextResponse.json({ - data: { exists: companies.length > 0, companies }, + data: { exists: companies.length > 0, companies, exists_elsewhere: existsElsewhere }, }) } diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 6553e51c..2972dcbe 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -2,7 +2,10 @@ import { useState } from 'react' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' +import { createClient } from '@/lib/supabase/client' +import { AttnLine } from '@/components/ui/attn-line' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { useCapability, useCompany } from '@/contexts/CompanyContext' @@ -30,6 +33,12 @@ interface DashboardContentProps { suggestedMatches: SuggestedMatch[] /** In-progress work for the Fortsätt pane (lib/worklist/resume). */ resumeItems: ResumeItem[] + /** + * True when this account looks bookkeeping-empty while a same-orgnr + * company with real bookkeeping exists in another account (#1231): the + * user probably signed in with the wrong login (stale BankID account). + */ + otherAccountHint?: boolean onboardingProgress?: OnboardingProgress initialSetup: InitialSetupState /** @@ -55,6 +64,7 @@ export default function DashboardContent({ worklist, suggestedMatches, resumeItems, + otherAccountHint = false, onboardingProgress, initialSetup, agentBuilt = true, @@ -62,6 +72,15 @@ export default function DashboardContent({ const t = useTranslations('dashboard') const hasAi = useCapability(CAPABILITY.ai) const { company } = useCompany() + const router = useRouter() + + // Wrong-account hint action: sign out so the user can come back in with + // their other login (email+password). Same flow as SandboxBanner. + async function handleSwitchAccount() { + const supabase = createClient() + await supabase.auth.signOut() + router.push('/login') + } // Time-of-day greeting (concept: "God morgon, Jakob."). Client-side clock // on purpose (the user's local morning, not the server's), captured once @@ -89,6 +108,14 @@ export default function DashboardContent({ {dateLine} {company?.name ? ` · ${company.name}` : ''}

+ {otherAccountHint && ( + + {t('other_account_hint')} + + )} (null) const [monogram, setMonogram] = useState(null) const [dupName, setDupName] = useState(null) + const [dupElsewhere, setDupElsewhere] = useState(false) const station = stationOfStep(state.step) const entity = state.settings.entity_type @@ -128,6 +129,7 @@ export default function OnboardingJourney({ return } setDupName(null) + setDupElsewhere(false) dispatch({ type: 'ORG_SUBMITTED', orgNumber: raw }) fetchCompanyLookup(raw, { ticEnabled }).then((outcome) => { dispatch({ type: 'LOOKUP_RESULT', outcome }) @@ -137,6 +139,7 @@ export default function OnboardingJourney({ if (!res.ok) return const { data } = await res.json() setDupName(data?.companies?.[0]?.name ?? null) + setDupElsewhere(Boolean(data?.exists_elsewhere)) }) .catch(() => {}) }, @@ -695,6 +698,16 @@ export default function OnboardingJourney({ {t('journey_dup_note', { name: dupName })} ) : null} + {!dupName && dupElsewhere && station === 0 ? ( + // Cross-account duplicate (#1231): the same org number + // already exists under another Accounted account. Shown + // only when there is no own-account match, which is the + // more specific hint. + + {lookupFacts.length > 0 ? ' · ' : ''} + {t('journey_dup_elsewhere_note')} + + ) : null} )} diff --git a/lib/company/__tests__/other-account-hint.test.ts b/lib/company/__tests__/other-account-hint.test.ts new file mode 100644 index 00000000..23c9ed81 --- /dev/null +++ b/lib/company/__tests__/other-account-hint.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: vi.fn(), +})) + +import { createServiceClient } from '@/lib/supabase/server' +import { shouldShowOtherAccountHint } from '../other-account-hint' +import type { SupabaseClient } from '@supabase/supabase-js' + +const mockCreateServiceClient = vi.mocked(createServiceClient) + +/** + * Chainable query mock keyed by table name: every method returns the chain, + * awaiting it resolves with the configured { data, error } for that table. + */ +function buildClient(resultsByTable: Record) { + return { + from: vi.fn((table: string) => { + const result = { + data: resultsByTable[table]?.data ?? null, + error: resultsByTable[table]?.error ?? null, + } + const chain: Record = {} + for (const m of ['select', 'eq', 'in', 'is', 'limit', 'order', 'range']) { + chain[m] = () => chain + } + ;(chain as { then?: unknown }).then = (resolve: (v: unknown) => void) => resolve(result) + return chain + }), + } +} + +const OWN_COMPANY = { id: 'own-co', org_number: '5560125790' } + +beforeEach(() => { + vi.clearAllMocks() + mockCreateServiceClient.mockReturnValue( + buildClient({ companies: { data: [] }, journal_entries: { data: [] } }) as never, + ) +}) + +describe('shouldShowOtherAccountHint', () => { + it('is false when the account has journal entries (common case, no probe)', async () => { + const supabase = buildClient({ + companies: { data: [OWN_COMPANY] }, + journal_entries: { data: [{ id: 'je1' }] }, + }) + await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false) + expect(mockCreateServiceClient).not.toHaveBeenCalled() + }) + + it('is true when the account is empty and a same-orgnr company elsewhere has entries', async () => { + const supabase = buildClient({ + companies: { data: [OWN_COMPANY] }, + journal_entries: { data: [] }, + }) + mockCreateServiceClient.mockReturnValue( + buildClient({ + companies: { data: [{ id: 'own-co' }, { id: 'other-co' }] }, + journal_entries: { data: [{ id: 'je-other' }] }, + }) as never, + ) + await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(true) + }) + + it('is false when the same-orgnr company elsewhere is also empty', async () => { + const supabase = buildClient({ + companies: { data: [OWN_COMPANY] }, + journal_entries: { data: [] }, + }) + mockCreateServiceClient.mockReturnValue( + buildClient({ + companies: { data: [{ id: 'own-co' }, { id: 'other-co' }] }, + journal_entries: { data: [] }, + }) as never, + ) + await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false) + }) + + it('is false when no other account shares the org number', async () => { + const supabase = buildClient({ + companies: { data: [OWN_COMPANY] }, + journal_entries: { data: [] }, + }) + mockCreateServiceClient.mockReturnValue( + buildClient({ + companies: { data: [{ id: 'own-co' }] }, + journal_entries: { data: [{ id: 'je-other' }] }, + }) as never, + ) + await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false) + }) + + it('is false when the user has no companies', async () => { + const supabase = buildClient({ companies: { data: [] }, journal_entries: { data: [] } }) + await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false) + }) + + it('is false when own companies have no org number', async () => { + const supabase = buildClient({ + companies: { data: [{ id: 'own-co', org_number: null }] }, + journal_entries: { data: [] }, + }) + await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false) + expect(mockCreateServiceClient).not.toHaveBeenCalled() + }) + + it('fails soft to false on query errors', async () => { + const supabase = buildClient({ + companies: { error: { message: 'boom' } }, + journal_entries: { data: [] }, + }) + await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false) + }) + + it('fails soft to false when the service client throws', async () => { + const supabase = buildClient({ + companies: { data: [OWN_COMPANY] }, + journal_entries: { data: [] }, + }) + mockCreateServiceClient.mockImplementation(() => { + throw new Error('no service key') + }) + await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false) + }) +}) diff --git a/lib/company/other-account-hint.ts b/lib/company/other-account-hint.ts new file mode 100644 index 00000000..77072c49 --- /dev/null +++ b/lib/company/other-account-hint.ts @@ -0,0 +1,78 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createServiceClient } from '@/lib/supabase/server' +import { fetchAllRows } from '@/lib/supabase/fetch-all' + +/** + * Should the Hem page hint that the user may be signed in to the wrong + * account? (#1231, the "Chillen" support case: BankID resolved to a stale + * signup account while all real bookkeeping lived in a second + * email+password account with the same org number.) + * + * True only when BOTH hold: + * 1. Every company the caller can see has zero journal entries (their + * account is bookkeeping-empty), and + * 2. a company with one of the same org numbers, in an account they are + * NOT a member of, has at least one journal entry. + * + * The common case (an account with any bookkeeping at all) exits after one + * indexed existence probe. The cross-account probe runs on the service + * client but the result reduces to one boolean: nothing about the other + * account is revealed beyond "your bookkeeping may live elsewhere". + * Fails soft to false: this is an advisory line, never worth an error. + */ +export async function shouldShowOtherAccountHint(supabase: SupabaseClient): Promise { + try { + // RLS scopes both reads to the caller's memberships. Company lists are + // paginated with fetchAllRows (PostgREST caps at 1000 rows; a byrå user + // can belong to many companies); it throws on error, which the outer + // catch turns into false. The journal_entries read stays a bare + // limit(1): it is an existence probe, not a listing. + const [ownCompanies, { data: ownEntries, error: entriesError }] = await Promise.all([ + fetchAllRows<{ id: string; org_number: string | null }>(({ from, to }) => + supabase + .from('companies') + .select('id, org_number') + .is('archived_at', null) + .order('id') + .range(from, to), + ), + supabase.from('journal_entries').select('id').limit(1), + ]) + + if (entriesError) return false + if (ownCompanies.length === 0) return false + if ((ownEntries ?? []).length > 0) return false + + const ownIds = new Set(ownCompanies.map((c) => c.id)) + const orgNumbers = [ + ...new Set(ownCompanies.map((c) => c.org_number).filter((n): n is string => Boolean(n))), + ] + if (orgNumbers.length === 0) return false + + const service = createServiceClient() + const sameOrgCompanies = await fetchAllRows<{ id: string }>(({ from, to }) => + service + .from('companies') + .select('id') + .in('org_number', orgNumbers) + .is('archived_at', null) + .order('id') + .range(from, to), + ) + + const otherIds = sameOrgCompanies.map((c) => c.id).filter((id) => !ownIds.has(id)) + if (otherIds.length === 0) return false + + const { data: otherEntries, error: otherEntriesError } = await service + .from('journal_entries') + .select('id') + .in('company_id', otherIds) + .limit(1) + + if (otherEntriesError) return false + return (otherEntries ?? []).length > 0 + } catch { + // Service key unavailable (some self-hosted setups) or transient failure. + return false + } +} diff --git a/messages/en.json b/messages/en.json index 70fe7b5c..b9b33225 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1330,6 +1330,7 @@ "journey_ans_monthly": "Monthly", "journey_ans_yearly": "Yearly", "journey_dup_note": "You already have {name} in Accounted.", + "journey_dup_elsewhere_note": "This company already exists in Accounted. Do you have another account from before? Sign in there instead, or ask for an invitation.", "journey_pending_invite_note": "You have a pending invitation to a company. Open the link in the invitation email to join instead.", "journey_fact_vat": "VAT registered", "journey_fact_ceased": "Deregistered", @@ -5311,6 +5312,8 @@ "greeting_morning": "Good morning", "greeting_day": "Good day", "greeting_evening": "Good evening", + "other_account_hint": "This account is empty, but bookkeeping for the same organisation number exists in another Accounted account. Did you sign in with the wrong login?", + "other_account_hint_action": "Switch account", "resume_title": "Continue", "resume_invoice_draft": "Draft: invoice to {customer}", "resume_invoice_unsent": "Send invoice {number}", diff --git a/messages/sv.json b/messages/sv.json index c487e355..f39e03c6 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1330,6 +1330,7 @@ "journey_ans_monthly": "Månadsvis", "journey_ans_yearly": "Årsvis", "journey_dup_note": "Du har redan {name} i Accounted.", + "journey_dup_elsewhere_note": "Det här bolaget finns redan i Accounted. Har du ett annat konto sedan tidigare? Logga in där istället, eller be om en inbjudan.", "journey_pending_invite_note": "Du har en väntande inbjudan till ett företag. Öppna länken i inbjudningsmejlet för att gå med istället.", "journey_fact_vat": "Momsregistrerat", "journey_fact_ceased": "Avregistrerat", @@ -5311,6 +5312,8 @@ "greeting_morning": "God morgon", "greeting_day": "God dag", "greeting_evening": "God kväll", + "other_account_hint": "Det här kontot är tomt, men bokföring för samma organisationsnummer finns i ett annat Accounted-konto. Loggade du in med fel inloggning?", + "other_account_hint_action": "Byt konto", "resume_title": "Fortsätt", "resume_invoice_draft": "Utkast: faktura till {customer}", "resume_invoice_unsent": "Skicka faktura {number}", diff --git a/scripts/support/unlink-bankid.ts b/scripts/support/unlink-bankid.ts new file mode 100644 index 00000000..5b38a860 --- /dev/null +++ b/scripts/support/unlink-bankid.ts @@ -0,0 +1,200 @@ +/** + * Support action: unlink a BankID identity from an account (#1231). + * + * WHY: when a user's personnummer is linked to a stale/abandoned account, + * BankID login strands them there (the Chillen support case, 2026-07-27) and + * /bankid/link on their real account returns 409 already_linked. The safe + * support fix is to unlink the stale account: that grants nobody access + * (re-linking still requires a password login to the target account plus a + * live BankID session), it just frees the personnummer. + * + * What it does on --execute: + * 1. writes an append-only audit_log row (SECURITY_EVENT) carrying the full + * old row FIRST: if the audit insert fails nothing is deleted, and if the + * delete fails the audit row merely over-records (the safe direction), + * 2. deletes the bankid_identities row for the user, + * 3. clears app_metadata.bankid_linked (read-merge-write: updateUserById + * replaces app_metadata wholesale, see app/api/account/password/route.ts). + * + * The dry run prints only non-sensitive account context (never the + * personnummer hash or ciphertext: the unsalted hash is brute-forceable + * over the small personnummer space). The restore path is the audit_log + * row's old_state, readable with the service key. + * + * Usage: + * npx tsx scripts/support/unlink-bankid.ts --email user@example.se --reason "GH-1234" # dry run + * npx tsx scripts/support/unlink-bankid.ts --email user@example.se --reason "GH-1234" --execute # performs the unlink + * (accepts --user-id instead of --email when the profile has no email) + * + * Reads NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY from .env.local. + * Treat .env.local as pointing at PRODUCTION: the dry run is read-only. + */ +import { createClient } from '@supabase/supabase-js' +import { config as dotenv } from 'dotenv' +import { resolve } from 'node:path' + +dotenv({ path: resolve(process.cwd(), '.env.local') }) + +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL +const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY +if (!SUPABASE_URL || !SERVICE_KEY) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local') + process.exit(1) +} + +function argValue(flag: string): string | null { + const i = process.argv.indexOf(flag) + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : null +} + +const EXECUTE = process.argv.includes('--execute') +const EMAIL = argValue('--email')?.trim().toLowerCase() ?? null +const USER_ID = argValue('--user-id')?.trim() ?? null +const REASON = argValue('--reason')?.trim() ?? null + +if (!EMAIL && !USER_ID) { + console.error('Usage: npx tsx scripts/support/unlink-bankid.ts --email [--reason ] [--execute]') + process.exit(1) +} +if (EXECUTE && !REASON) { + console.error('--execute requires --reason (support ticket / issue reference for the audit log)') + process.exit(1) +} + +const sb = createClient(SUPABASE_URL, SERVICE_KEY, { auth: { persistSession: false } }) + +async function main() { + // Resolve the user. profiles mirrors auth emails for active accounts; + // anonymized accounts may lack it, hence the --user-id escape hatch. + let userId = USER_ID + if (!userId) { + const { data: profile, error } = await sb + .from('profiles') + .select('id, email') + .eq('email', EMAIL) + .maybeSingle() + if (error) { + console.error('profiles lookup failed:', error.message) + process.exit(1) + } + if (!profile) { + console.error(`No profile with email ${EMAIL}. If the account is anonymized, pass --user-id.`) + process.exit(1) + } + userId = profile.id + } + if (!userId) { + console.error('Could not resolve a user id') + process.exit(1) + } + + const { data: authUser, error: authError } = await sb.auth.admin.getUserById(userId) + if (authError || !authUser?.user) { + console.error('auth user not found:', authError?.message ?? userId) + process.exit(1) + } + + const { data: identity, error: identityError } = await sb + .from('bankid_identities') + .select('id, user_id, personal_number_hash, personal_number_enc, given_name, surname, linked_at, created_at, updated_at') + .eq('user_id', userId) + .maybeSingle() + if (identityError) { + console.error('bankid_identities lookup failed:', identityError.message) + process.exit(1) + } + if (!identity) { + console.log(`No BankID identity linked to ${authUser.user.email} (${userId}). Nothing to do.`) + process.exit(0) + } + + // Account context so the operator can confirm this is the STALE account + // (the expected shape: few companies, no journal entries). Fail closed: + // a failed context query must never make an unknown account look empty. + const { data: memberships, error: membershipsError } = await sb + .from('company_members') + .select('company_id, companies:company_id(name, org_number)') + .eq('user_id', userId) + if (membershipsError) { + console.error('company_members lookup failed, aborting:', membershipsError.message) + process.exit(1) + } + const companyIds = (memberships ?? []).map((m) => m.company_id) + let entryCount = 0 + if (companyIds.length > 0) { + const { count, error: entriesError } = await sb + .from('journal_entries') + .select('*', { count: 'exact', head: true }) + .in('company_id', companyIds) + if (entriesError || count === null) { + console.error('journal_entries count failed, aborting:', entriesError?.message ?? 'null count') + process.exit(1) + } + entryCount = count + } + + console.log('-- BankID unlink ------------------------------------------') + console.log('account: ', authUser.user.email, `(${userId})`) + console.log('bankid holder: ', [identity.given_name, identity.surname].filter(Boolean).join(' ')) + console.log('linked since: ', identity.linked_at) + console.log('companies: ', (memberships ?? []).map((m) => { + const c = m.companies as unknown as { name?: string; org_number?: string } | null + return `${c?.name ?? '?'} (${c?.org_number ?? 'no orgnr'})` + }).join(', ') || 'none') + console.log('journal entries:', entryCount) + if (entryCount > 0) { + console.log('WARNING: this account has real bookkeeping. Unlinking BankID from an') + console.log('ACTIVE account is unusual: double-check you have the right one.') + } + console.log('identity row id: ', identity.id) + console.log('-----------------------------------------------------------') + + if (!EXECUTE) { + console.log('Dry run. Re-run with --reason --execute to unlink.') + return + } + + // Append-only audit trail FIRST, so a partial failure can never leave a + // deletion without a trace. The full old row (including hash + ciphertext) + // lives only here, RLS-protected; user_id = the affected user, so the + // entry is visible to them under the audit_log RLS select policy. + const { error: auditError } = await sb.from('audit_log').insert({ + user_id: userId, + action: 'SECURITY_EVENT', + table_name: 'bankid_identities', + record_id: identity.id, + old_state: identity, + description: `support unlink-bankid (delete follows this entry): ${REASON}`, + }) + if (auditError) { + console.error('audit_log insert failed, aborting BEFORE delete. Nothing changed.') + console.error('Error:', auditError.message) + process.exit(1) + } + + const { error: deleteError } = await sb + .from('bankid_identities') + .delete() + .eq('id', identity.id) + if (deleteError) { + console.error('DELETE failed AFTER the audit row was written: the audit entry') + console.error(`(record_id ${identity.id}) over-records; the identity row still exists.`) + console.error('Error:', deleteError.message) + process.exit(1) + } + + // Clear the settings-page "BankID linked" flag. Merge, never replace. + const priorMeta = authUser.user.app_metadata ?? {} + const { error: metaError } = await sb.auth.admin.updateUserById(userId, { + app_metadata: { ...priorMeta, bankid_linked: false }, + }) + if (metaError) { + console.error('app_metadata update failed (unlink itself succeeded):', metaError.message) + } + + console.log('Unlinked. Restore path: audit_log old_state for record_id', identity.id) + console.log('The user can now link BankID from their other account:') + console.log('password login there, then Inställningar → Konto → koppla BankID.') +} + +main()