From a769bc9d037596441f9ae376115cda8f7f282d6c Mon Sep 17 00:00:00 2001
From: Jakob Wennberg
Date: Mon, 7 Sep 2026 15:38:07 +0200
Subject: [PATCH] =?UTF-8?q?feat(migration):=20Bj=C3=B6rn=20Lund=C3=A9n=20a?=
=?UTF-8?q?ctivation=20through=20Lundify's=20redirect=20flow=20(#2374)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(migration): Björn Lundén activation through Lundify's redirect flow
BL issued our integration activation key on 2026-09-07. With
BJORN_LUNDEN_ACTIVATION_KEY set, the connect step offers "Aktivera i
Lundify": the customer logs in at Lundify, picks the company and accepts
the scopes, and Lundify returns the company's User-Key to our callback as
publicKey with our one-time state echoed as extra. The manual User-Key
field stays as a folded fallback for companies that activated inside
Lundify already.
The callback folds publicKey/extra into the OAuth-shaped locals, so the
atomic state consumption, initiator binding and white-label handoff run
unchanged; only the final step differs: submitProviderToken (the same
client-credentials probe as the manual field) instead of an OAuth code
exchange, owned by the consent's company read from the server-written row.
consumeOAuthState/consumeHandoff now return that company id.
Closes #2323.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L
* fix(migration): reset the previous connect attempt before a new provider request
Review follow-up: a failed /connect used to leave the earlier consent id
and one-time activation URL in place, so the step kept offering a link
that completed the previous consent.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1
---
.env.example | 4 +
DECISIONS.md | 1 +
.../general/ArcimMigrationWorkspace.tsx | 89 ++++--
.../__tests__/bl-lundify-activation.test.ts | 274 ++++++++++++++++++
.../__tests__/oauth-callback-state.test.ts | 2 +-
.../provider-client-oauth-state.test.ts | 21 +-
extensions/general/arcim-migration/index.ts | 87 +++++-
.../arcim-migration/lib/provider-client.ts | 21 +-
.../general/arcim-migration/manifest.json | 1 +
.../bjornlunden/__tests__/activation.test.ts | 56 ++++
lib/providers/bjornlunden/activation.ts | 53 ++++
messages/en.json | 3 +
messages/sv.json | 3 +
13 files changed, 570 insertions(+), 45 deletions(-)
create mode 100644 extensions/general/arcim-migration/__tests__/bl-lundify-activation.test.ts
create mode 100644 lib/providers/bjornlunden/__tests__/activation.test.ts
create mode 100644 lib/providers/bjornlunden/activation.ts
diff --git a/.env.example b/.env.example
index 0753252d..e94a1bbd 100644
--- a/.env.example
+++ b/.env.example
@@ -206,6 +206,10 @@ GOOGLE_MAIL_CONNECT_COMPANY_IDS=
# User-Key is entered by the user in the migration wizard)
# BJORN_LUNDEN_CLIENT_ID=
# BJORN_LUNDEN_CLIENT_SECRET=
+# Integration activation key issued by BL for the service provider. When set,
+# the wizard offers "Aktivera i Lundify" (redirect flow that returns the
+# User-Key itself); when unset only the manual User-Key field is shown.
+# BJORN_LUNDEN_ACTIVATION_KEY=
# WhatsApp receipt intake (whatsapp-inbox extension, Meta Cloud API).
# ACCESS_TOKEN: system-user permanent token with whatsapp_business_messaging
# scope only. PHONE_NUMBER_ID: the Graph object id of the sending number.
diff --git a/DECISIONS.md b/DECISIONS.md
index a1401959..ea82ee2a 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1636,6 +1636,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and
[2026-09-06] Bundled SKV ROT/RUT payout books ONE voucher (one 1513 leg per begäran) and the set is suggested at read time with no hint column: one bank row = one verifikat (match-batch precedent) and a uuid[] hint would need six clear paths and go stale; N vouchers + the 1:N reconciliation split was rejected because its half-failure state has no UI exit, and begäran, not the invoice, is the unit under fakturamodellen.
[2026-09-06] Utlägg via lön settles claims with an idempotent RPC after the salary verifikat is posted (pre-checked before posting), not with a trigger on salary_runs -> booked: a raise inside that trigger after the entries exist would leave a paid run with posted verifikat and a retry would double-post; the RPC path fails to "booked, claims still open, re-runnable".
[2026-09-06] A privately paid supplier invoice is booked through registerExpenseClaim (verifikat + expense_claims row, source_type expense_claim) with the invoice's kontering as custom lines, and a person-paid inbox document goes to the core route with inbox_item_id instead of the extension's convert endpoint: the form's switch, the second entry generator and the convert bypass were three write paths for one fact, so one writer wins over adding a claims insert beside the old generator (the issue's shape) or copying the branch into the convert handler.
+[2026-09-07] Björn Lundén connect: Lundify activation redirect reuses the OAuth state row (provider_otc) and the callback, with the User-Key arriving as publicKey and the state as extra; the manual User-Key field stays as a folded fallback: BL issued the activation key on 2026-09-07, the redirect removes the GUID copy-paste that failed every real customer, and reusing the atomic state consumption plus initiator binding keeps the same tenant boundary as Fortnox/Visma instead of a second callback with its own checks. BL's ON/OFF backend callback and settings URL are not built: they need a registered URL at BL and an orgNr-to-company mapping, filed as follow-up.
[2026-09-07] Auth-link hosts resolve against the brands table only; NEXT_PUBLIC_WHITELABEL_DOMAINS removed (supersedes 2026-08-18): the env var was a second copy of brands.domain compiled into the browser, so every brand needed four registrations (row, env var, GoTrue allowlist, redeploy) and two partners shipped with it stale (canonical-branded reset mails). Password reset moved to POST /api/auth/password-reset so the server resolves the host; invite, email change and signup share the same resolver, which also trusts this deployment's own VERCEL_URL/VERCEL_BRANCH_URL so previews keep working. A drift check between the copies was rejected: it would be a fifth thing to maintain. GoTrue's redirect allowlist stays as the backstop; hosted carries the wildcards https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/** there (config, not code; GoTrue matches the full URL with query, and * stops at . and /) so only bring-your-own-domain partners need a manual entry. A failed brands lookup refuses with 503 (BrandLookupFailedError) instead of a canonical fallback: a canonical link is a wrong-brand mail for a white-label user, which is the bug this replaces.
[2026-09-07] Draft stamp moved to the page margin (absolute + fixed) instead of the reporter's position:fixed corner badge: the 40pt top margin is the only place that is guaranteed empty on every page, and the stamp must not overlap the header title on the right.
[2026-09-07] Hyphenation disabled per Text node in the invoice template, not via a global Font.registerHyphenationCallback: the global hook would also change line breaking in årsredovisning, payslips and every report PDF; that is a separate decision.
diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx
index a568eacc..fb436c88 100644
--- a/components/extensions/general/ArcimMigrationWorkspace.tsx
+++ b/components/extensions/general/ArcimMigrationWorkspace.tsx
@@ -690,6 +690,7 @@ function ConnectStep({
isLoading,
error,
authUrl,
+ activationUrl,
consentId,
onTokenSubmit,
onBack,
@@ -699,6 +700,8 @@ function ConnectStep({
isLoading: boolean
error: string | null
authUrl: string | null
+ /** Björn Lundén only: Lundify's activation redirect, when BL issued us a key. */
+ activationUrl: string | null
consentId: string | null
onTokenSubmit: (apiToken: string, companyId: string) => void
onBack: () => void
@@ -707,9 +710,33 @@ function ConnectStep({
const providerName = ARCIM_PROVIDERS.find(p => p.id === provider)?.name ?? provider
const [apiToken, setApiToken] = useState('')
const [companyId, setCompanyId] = useState('')
+ // With the Lundify redirect on offer, the User-Key field is the fallback for
+ // a customer who activated inside Lundify already, so it starts folded.
+ const [showManualKey, setShowManualKey] = useState(false)
// BL uses server-side client credentials: only needs company ID, no API key
const isClientCredentials = provider === 'bjornlunden'
+ const hasLundifyActivation = isClientCredentials && !!activationUrl
+ const manualKeyVisible = !hasLundifyActivation || showManualKey
+
+ const openProviderWindow = (url: string) => {
+ const w = 600
+ const h = 700
+ const left = window.screenX + (window.outerWidth - w) / 2
+ const top = window.screenY + (window.outerHeight - h) / 2
+ const popup = window.open(url, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
+ if (!popup) {
+ // Popup blocked: with the return value discarded, a blocked
+ // popup looked exactly like a successful one (nothing opens,
+ // nothing is said, the user clicks again). Fall back to the
+ // full-page flow instead. The callback already supports it:
+ // with no window.opener it redirects to
+ // /import?migration=connected&consentId=..., which
+ // handleOAuthReturn consumes and resumes the wizard at the
+ // preview step. Same treatment as SkatteverketConnectPanel.
+ window.location.href = url
+ }
+ }
// WINT has no API keys: the "token" is the user's WINT login (e-post +
// lösenord), exchanged server-side for ett tokenpar; lösenordet sparas aldrig.
const isWintLogin = provider === 'wint'
@@ -727,7 +754,9 @@ function ConnectStep({
? t('ext_arcim_bokio_company_id_label')
: 'Företags-ID'
- const tokenDescription = isClientCredentials
+ const tokenDescription = hasLundifyActivation
+ ? t('ext_arcim_bl_activate_description', { appName: branding.appName })
+ : isClientCredentials
? t('ext_arcim_bl_token_description', { appName: branding.appName })
: isWintLogin
? `Logga in med dina WINT-uppgifter för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata. Lösenordet används en gång för att skapa anslutningen och sparas aldrig.`
@@ -786,35 +815,36 @@ function ConnectStep({
Klicka nedan för att logga in i {providerName}.
Fönstret stängs automatiskt när du är klar.