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
co-authored by Claude Fable 5
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,