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()
]