* 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>
79 lines
3.1 KiB
TypeScript
79 lines
3.1 KiB
TypeScript
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
|
|
}
|
|
}
|