fix(tab-guard): name the company the other tab switched to (#2328)

The cross-tab guard dialog said "another tab switched the active company"
without saying to which one, so the two exits read as "your company" versus
"the new one". Resolve the observed company id against the memberships the
shell already ships to the client (switcher list plus the foreign-host
signpost list; no request at the moment the tab is told to stop) and say
"en annan flik har bytt till Demo AB" and "Ladda om som Demo AB". Unknown
ids keep the unnamed wording.

Founder re-confirmed the blocking two-exit design (WL-09) today after a
forensic pass on a real firing; this is copy only, no behaviour change.


Claude-Session: https://claude.ai/code/session_01CQG9jNyxM7mwMUHWBrFUxY

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 17:27:08 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 473b1fd2eb
commit b0b995c77c
6 changed files with 75 additions and 6 deletions
+1
View File
@@ -1604,4 +1604,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-05] Supplier and customer pages: the register fills the row's own contact fields (e-mail, phone, postal address, VAT number) when they are empty or still carry what the register said last time, marked "från SCB" by equality with the registry fact, never a value a person typed. Chosen over a read-only fallback because the row is what payment files and documents use; provenance by equality instead of a source column because it needs no schema and a person's edit ends it by itself. Företagsuppgifter keeps only what the register alone knows (status line, industry, seat, size). Agents get the party read-only first: ?expand=party on v1 supplier/customer detail, party_id on list rows, and gnubok_get_party in MCP; the parties resource (suggest, promote, enrich) comes as its own v1 surface next.
[2026-09-05] Invoice PDF statutory notices follow the document language: the export notice (stored in Swedish on invoices.reverse_charge_text at create time) is matched against the shared EXPORT_NOTICE_SV constant and rendered from LABELS, so existing invoices are fixed too, and the English footer reads "Approved for F-tax (Godkänd för F-skatt)". Rejected translating at create time (would leave every existing export invoice Swedish) and deriving purely from vat_treatment (would override custom or v1-supplied text). The old Swedish-only F-skatt footer cited Peppol SE-R-005, which governs the UBL file, not the PDF; the swedish-invoice-compliance skill (invoice-rules.md §4) states ML has no language requirement for invoice text, and the literal Swedish phrase stays on the English PDF in parentheses. All notice boxes (proforma, quote, VAT notice, notes) now share one neutral style; custom invoice layouts are a separate feature request.
[2026-09-05] Utlägg becomes an answer, not a page: the Underlag pane asks "Vem betalade?" (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims; the person owed surfaces as a Betala row in Att göra (lib/worklist expense_payout, one item per person) and the Utlägg nav row is gated on existing claims like Körjournal. Chosen over a fourth item in the Bokföring split button (that menu is three ways to type one verifikat, not a list of document kinds) and over keeping the two-step wizard as the entry point: a kvitto paid with a private card differs from any other purchase only in the credit account, and 93 percent of companies on prod are owner-only, for whom a module for that one bit is the wrong shape. Phase 2 (bank-driven repayment, open items shared with leverantörsfakturor, via lön) and phase 3 (retire the wizard, per-person list under Löner) are filed as follow-ups.
[2026-09-05] Cross-tab company guard (WL-09) stays a blocking two-exit dialog, founder re-confirmed today after a forensic pass on a real firing (a switch made elsewhere under the same login, no server-side or agent path involved): auto-follow, per-tab company scoping and a reads-continue banner were offered and declined. Only change: the dialog now names the company the other tab switched to (resolved from the memberships the shell already ships to the client, no request), so the two exits read as a choice between two named companies instead of a named one and "the new one".
[2026-09-05] Björn Lundén connect: a 403 whose body says "out of allowed scope for service provider" is mapped to its own BL_INTEGRATION_NOT_ACTIVATED verdict (the key is right, the company never activated the integration) instead of the generic "leverantören avvisade autentiseringen"; live-verified against a real customer key, where every read endpoint answered exactly that while a made-up key answered 500. Root cause of every failed BL connect in prod (10 consents, only BL's own sandbox company ever got tokens): the integration is still a sandbox listing at BL, so no real company can activate it. Chose a message that names the fix (activate in Lundify, else SIE) over hiding the provider state; the Lundify activation-redirect flow and document/line-level fetching are filed as follow-ups rather than built blind before BL releases the integration.
+25 -4
View File
@@ -12,6 +12,7 @@ import {
guardStore,
isTabMismatch,
requestHasNextActionHeader,
resolveObservedCompanyName,
shouldBlockMutation,
} from '@/lib/company/tab-guard'
@@ -119,10 +120,16 @@ function uninstallFetchGuard(): void {
}
export default function CompanyTabSync() {
const { company } = useCompany()
const { company, companies, foreignCompanies } = useCompany()
const t = useTranslations('company_tab_guard')
const currentCompanyId = company?.id ?? null
const [mismatch, setMismatch] = useState(false)
// The company the other tab switched to, so the dialog can name it: the
// choice between "switch back" and "reload as the new one" is only obvious
// when both sides are named. Read from the guard store at the moment the
// dialog is raised (both the observe path and the blocked-write path set
// observedCompanyId first).
const [observedCompanyId, setObservedCompanyId] = useState<string | null>(null)
const [resolving, setResolving] = useState(false)
useEffect(() => {
@@ -134,7 +141,10 @@ export default function CompanyTabSync() {
// A self-initiated switch is hard-navigating this tab away: blocked
// stray writes still get their 409, but the "switched in another tab"
// dialog would just flash over the tab's own page load.
if (!guardStore.selfSwitchTargetId) setMismatch(true)
if (!guardStore.selfSwitchTargetId) {
setObservedCompanyId(guardStore.observedCompanyId)
setMismatch(true)
}
}
installFetchGuard()
@@ -143,6 +153,7 @@ export default function CompanyTabSync() {
guardStore.observedCompanyId = observedId
if (observedId === guardStore.selfSwitchTargetId) return
if (isTabMismatch(currentCompanyId, observedId)) {
setObservedCompanyId(observedId)
setMismatch(true)
}
}
@@ -212,6 +223,12 @@ export default function CompanyTabSync() {
if (!mismatch || !currentCompanyId) return null
const newCompanyName = resolveObservedCompanyName(
observedCompanyId,
companies,
foreignCompanies ?? [],
)
const handleSwitchBack = async () => {
setResolving(true)
// Re-activate THIS tab's company and reload the page we are on (same
@@ -249,7 +266,9 @@ export default function CompanyTabSync() {
{t('title')}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t('body', { company: company?.name ?? '' })}
{newCompanyName
? t('body_named', { company: company?.name ?? '', newCompany: newCompanyName })
: t('body', { company: company?.name ?? '' })}
</p>
<div className="mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
@@ -257,7 +276,9 @@ export default function CompanyTabSync() {
disabled={resolving}
onClick={handleReloadAsNew}
>
{t('reload_as_new')}
{newCompanyName
? t('reload_as_named', { newCompany: newCompanyName })
: t('reload_as_new')}
</Button>
<Button disabled={resolving} onClick={() => void handleSwitchBack()}>
{t('switch_back', { company: company?.name ?? '' })}
+23
View File
@@ -8,6 +8,7 @@ import {
markCompanySwitchInFlight,
markSelfSwitchTarget,
requestHasNextActionHeader,
resolveObservedCompanyName,
shouldBlockMutation,
} from '../tab-guard'
@@ -244,3 +245,25 @@ describe('guardBrowserWrite', () => {
expect(guardBrowserWrite()).toBe(true)
})
})
describe('resolveObservedCompanyName', () => {
const companies = [
{ company: { id: 'c-arcim', name: 'Arcim Technology AB' } },
{ company: { id: 'c-demo', name: 'Demo AB' } },
]
const foreign = [{ id: 'c-byra', name: 'Klientbolaget AB' }]
it('names a company from the switcher list', () => {
expect(resolveObservedCompanyName('c-demo', companies, foreign)).toBe('Demo AB')
})
it('names a company homed on another host from the signpost list', () => {
expect(resolveObservedCompanyName('c-byra', companies, foreign)).toBe('Klientbolaget AB')
})
it('is null for unknown ids and for no observation, so the dialog keeps its unnamed wording', () => {
expect(resolveObservedCompanyName('c-elsewhere', companies, foreign)).toBeNull()
expect(resolveObservedCompanyName(null, companies, foreign)).toBeNull()
expect(resolveObservedCompanyName(undefined, companies)).toBeNull()
})
})
+20
View File
@@ -161,6 +161,26 @@ export function isTabMismatch(
return observedCompanyId !== tabCompanyId
}
/**
* Name of the company another tab switched to, for the guard dialog. Looks
* through the memberships the shell already handed the client (the switcher
* list and the foreign-host signpost list), so no request is needed at the
* moment the tab is being told to stop. Null when the id is not among them
* (a company this login cannot see on this host): the dialog then falls back
* to its unnamed wording rather than guessing.
*/
export function resolveObservedCompanyName(
observedCompanyId: string | null | undefined,
companies: readonly { company: { id: string; name: string } }[],
foreignCompanies: readonly { id: string; name: string }[] = [],
): string | null {
if (!observedCompanyId) return null
const local = companies.find((entry) => entry.company.id === observedCompanyId)
if (local?.company.name) return local.company.name
const foreign = foreignCompanies.find((entry) => entry.id === observedCompanyId)
return foreign?.name || null
}
/**
* Whether a fetch about to leave this tab must be blocked. Guards mutating
* same-origin requests on two shapes:
+3 -1
View File
@@ -5886,7 +5886,9 @@
"title": "The active company was switched in another tab",
"body": "This tab shows {company}, but another tab switched the active company. Actions here would land in the wrong company's books. Choose how to continue.",
"switch_back": "Switch back to {company}",
"reload_as_new": "Reload with the new company"
"reload_as_new": "Reload with the new company",
"body_named": "This tab shows {company}, but another tab switched to {newCompany}. Actions here would land in the wrong company's books. Choose how to continue.",
"reload_as_named": "Reload as {newCompany}"
},
"signpost": {
"title": "This company is managed on another domain",
+3 -1
View File
@@ -5886,7 +5886,9 @@
"title": "Aktivt företag har bytts i en annan flik",
"body": "Den här fliken visar {company}, men en annan flik har bytt aktivt företag. Åtgärder här skulle hamna i fel bolags bokföring. Välj hur du vill fortsätta.",
"switch_back": "Byt tillbaka till {company}",
"reload_as_new": "Ladda om med det nya företaget"
"reload_as_new": "Ladda om med det nya företaget",
"body_named": "Den här fliken visar {company}, men en annan flik har bytt till {newCompany}. Åtgärder här skulle hamna i fel bolags bokföring. Välj hur du vill fortsätta.",
"reload_as_named": "Ladda om som {newCompany}"
},
"signpost": {
"title": "Företaget hanteras på en annan domän",