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:
@@ -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 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,
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user