feat(auth): surface duplicate-account traps around BankID login (#1234)

* feat(auth): surface duplicate-account traps around BankID login

Three escape hatches for the stale-duplicate-account trap (#1231, the
Chillen support case): a user whose BankID resolves to an abandoned
account got an empty app with no hint that their real bookkeeping
lives in another account.

- check-org-number: new exists_elsewhere signal (service role, reduced
  to one boolean) + a warn chip in the onboarding journey when the org
  number already exists in an account the user is not a member of.
- Hem: one AttnLine under the greeting when the whole account has zero
  journal entries but a same-orgnr company elsewhere has real
  bookkeeping, with a sign-out action. Common case costs one indexed
  existence probe.
- scripts/support/unlink-bankid.ts: dry-run-by-default support action
  that unlinks a BankID identity (delete + app_metadata clear +
  append-only SECURITY_EVENT audit_log row). Replaces the raw SQL used
  to resolve the original ticket.

Closes #1231

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): harden unlink script and paginate hint queries per review

- other-account-hint: fetchAllRows() on both company listings (PostgREST
  1000-row cap; byrå users can hold many memberships); the journal probes
  stay limit(1) existence checks.
- unlink-bankid: audit_log row is written BEFORE the delete so a partial
  failure can never delete without a trace; context queries fail closed
  instead of rendering an unknown account as empty; stdout no longer
  prints the personnummer hash or ciphertext (the unsalted hash is
  brute-forceable over the personnummer space); record_id now carries the
  identity row id and the snapshot includes id + linked_at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-27 17:00:15 +02:00
committed by GitHub
parent ff205951b1
commit 46c0b72ab0
10 changed files with 557 additions and 11 deletions
+7
View File
@@ -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,
@@ -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<string, unknown> = {}
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 callers 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,
+37 -10
View File
@@ -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 },
})
}
+27
View File
@@ -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}` : ''}
</p>
{otherAccountHint && (
<AttnLine
className="mt-3"
action={{ label: t('other_account_hint_action'), onClick: handleSwitchAccount }}
>
{t('other_account_hint')}
</AttnLine>
)}
</section>
<NewUserChecklist
@@ -93,6 +93,7 @@ export default function OnboardingJourney({
const [narration, setNarration] = useState<string | null>(null)
const [monogram, setMonogram] = useState<string | null>(null)
const [dupName, setDupName] = useState<string | null>(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 })}
</span>
) : 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.
<span className="jny-f is-on is-warn" style={{ transitionDelay: `${lookupFacts.length * 150}ms` }}>
{lookupFacts.length > 0 ? ' · ' : ''}
{t('journey_dup_elsewhere_note')}
</span>
) : null}
</div>
</>
)}
@@ -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<string, { data?: unknown; error?: unknown }>) {
return {
from: vi.fn((table: string) => {
const result = {
data: resultsByTable[table]?.data ?? null,
error: resultsByTable[table]?.error ?? null,
}
const chain: Record<string, unknown> = {}
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)
})
})
+78
View File
@@ -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<boolean> {
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
}
}
+3
View File
@@ -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}",
+3
View File
@@ -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}",
+200
View File
@@ -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 <uuid> 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 <email> [--reason <ref>] [--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 <ref> --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()