* fix(scoping): skatteverket per company + true pristine gates + scoped dismissals Skatteverket connections become per (user, company): the token table carried BOTH UNIQUE(user_id) and UNIQUE(company_id) (two stacked half migrations), so one connection leaked "connected" onto every company the user belongs to, sync ran the token against the wrong orgnr (behorighet 403), and reconnecting from another company silently moved the row and went dark on the first company's crons. Token reads/writes are now scoped by company through the whole chain (token-store, api-client refresh coalescing, skvRequest and its 21 call sites, resolve-auth, crons, MCP), /skattekonto/saldo answers 401 NOT_CONNECTED for companies without their own row (which is what the page's startkort keys on), and the dashboard connect-nudge counts only the active company's row. Bookkeeping's pristine start card now keys on all-years emptiness via a count probe instead of "no active filters": the default fiscal-year selection counted as a filter, which made the card unreachable on brand-new companies (it showed "inga traffar" instead). Two browser-global localStorage keys become company-scoped with legacy fallbacks: the inbox onboarding dismissal (dismissing on one company hid the card everywhere) and the periodisering auto-detect toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scoping): dedupe cron work per (user, company) + guard the ledger probe CodeRabbit findings on #1610: the skattekonto sync cron still deduped token rows by user_id alone, which would drop every company but one for multi-company operators (the exact scenario the PR fixes); and the all-years ledger probe could leave a stale false behind on a failed refetch, letting the pristine card render unconfirmed. The probe now resets to unknown in flight and carries the fetch generation guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { getSkatteverketEnvironment, type SkvAuth } from './api-client'
|
|
import { getSystemAuthMode, isSystemAuthConfigured } from './system-auth/config'
|
|
import {
|
|
getConnection,
|
|
type SkvBehorighet,
|
|
type SkvEnvironment,
|
|
} from './connection-store'
|
|
|
|
/**
|
|
* Auth resolution for background READ paths (skattekonto sync, kvittens
|
|
* polling, moms inlamnat/beslutat checks).
|
|
*
|
|
* Preference order:
|
|
* 1. System credentials, when SKATTEVERKET_SYSTEM_AUTH_MODE=on, the system
|
|
* flow is configured, and the company's connection row shows the
|
|
* required behorighet as granted for the current environment.
|
|
* 2. The company's user token (the pre-hybrid behavior), looked up by
|
|
* company_id like the kvittens cron always did.
|
|
*
|
|
* Write paths (moms utkast/las, AGI submit/spara/granskningsunderlag) stay
|
|
* hard-wired to user mode: the personal flow needs no ombud grant and the
|
|
* BankID signing step is personal by nature.
|
|
*
|
|
* Retiring user-token reads later (the full ombud switch) is a policy change
|
|
* inside this function only.
|
|
*/
|
|
|
|
export function currentSkvEnvironment(): SkvEnvironment {
|
|
return getSkatteverketEnvironment() === 'prod' ? 'production' : 'test'
|
|
}
|
|
|
|
export type ResolvedReadAuth =
|
|
| {
|
|
ok: true
|
|
auth: SkvAuth
|
|
source: 'system' | 'user'
|
|
/** The token-owning user (notification recipient); null in pure system mode. */
|
|
tokenUserId: string | null
|
|
}
|
|
| { ok: false; reason: 'no_token' | 'needs_reconsent' }
|
|
|
|
/** True when the company's connection row has the behorighet granted. */
|
|
export async function hasVerifiedGrant(
|
|
companyId: string,
|
|
behorighet: SkvBehorighet
|
|
): Promise<boolean> {
|
|
const connection = await getConnection(companyId, currentSkvEnvironment())
|
|
if (!connection) return false
|
|
const grant =
|
|
behorighet === 'lasombud' ? connection.lasombud_status : connection.moms_ombud_status
|
|
return grant === 'granted' && ['verified', 'partial'].includes(connection.status)
|
|
}
|
|
|
|
export async function resolveReadAuth(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
opts: { requires: SkvBehorighet; userId?: string }
|
|
): Promise<ResolvedReadAuth> {
|
|
if (getSystemAuthMode() === 'on' && isSystemAuthConfigured()) {
|
|
if (await hasVerifiedGrant(companyId, opts.requires)) {
|
|
return {
|
|
ok: true,
|
|
auth: { mode: 'system' },
|
|
source: 'system',
|
|
tokenUserId: opts.userId ?? (await lookupTokenUser(supabase, companyId))?.userId ?? null,
|
|
}
|
|
}
|
|
}
|
|
|
|
if (opts.userId) {
|
|
return {
|
|
ok: true,
|
|
auth: { mode: 'user', supabase, userId: opts.userId, companyId },
|
|
source: 'user',
|
|
tokenUserId: opts.userId,
|
|
}
|
|
}
|
|
|
|
const token = await lookupTokenUser(supabase, companyId)
|
|
if (!token) return { ok: false, reason: 'no_token' }
|
|
if (token.needsReconsent) return { ok: false, reason: 'needs_reconsent' }
|
|
|
|
return {
|
|
ok: true,
|
|
auth: { mode: 'user', supabase, userId: token.userId, companyId },
|
|
source: 'user',
|
|
tokenUserId: token.userId,
|
|
}
|
|
}
|
|
|
|
async function lookupTokenUser(
|
|
supabase: SupabaseClient,
|
|
companyId: string
|
|
): Promise<{ userId: string; needsReconsent: boolean } | null> {
|
|
// The token table is user-scoped (one BankID identity per user) but
|
|
// carries company_id: match on company so a multi-company operator's
|
|
// token is only used for the company that owns the work.
|
|
const { data } = await supabase
|
|
.from('skatteverket_tokens')
|
|
.select('user_id, status')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (!data?.user_id) return null
|
|
return {
|
|
userId: data.user_id as string,
|
|
needsReconsent: data.status === 'needs_reconsent',
|
|
}
|
|
}
|