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
+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>
</>
)}