feat(migration): Björn Lundén activation through Lundify's redirect flow (#2374)

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-07 15:38:07 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 8aa5d54510
commit a769bc9d03
13 changed files with 570 additions and 45 deletions
@@ -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.
</p>
<Button
className="min-h-11"
onClick={() => {
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(authUrl, '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 = authUrl
}
}}
>
<Button className="min-h-11" onClick={() => openProviderWindow(authUrl)}>
Logga in i {providerName}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
</div>
)}
{/* Björn Lundén: Lundify's activation redirect returns the User-Key
itself. The popup posts arcim-oauth-success like the OAuth
providers, so the same listener resumes the wizard. */}
{authType === 'token' && consentId && !isLoading && hasLundifyActivation && activationUrl && (
<div className="space-y-4">
<Button className="min-h-11" onClick={() => openProviderWindow(activationUrl)}>
{t('ext_arcim_bl_activate_button')}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
{!showManualKey && (
<Button
variant="link"
className="h-auto px-0 text-sm text-muted-foreground"
onClick={() => setShowManualKey(true)}
>
{t('ext_arcim_bl_manual_key_toggle')}
</Button>
)}
</div>
)}
{/* Token-based flow */}
{authType === 'token' && consentId && !isLoading && (
{authType === 'token' && consentId && !isLoading && manualKeyVisible && (
<div className="max-w-md space-y-4">
<p className="text-sm text-muted-foreground">
{tokenHelpText}
@@ -2278,6 +2308,9 @@ export default function ArcimMigrationWorkspace({
const [selectedProvider, setSelectedProvider] = useState<ArcimProvider | null>(null)
const [consentId, setConsentId] = useState<string | null>(null)
const [authUrl, setAuthUrl] = useState<string | null>(null)
// Björn Lundén: Lundify activation URL from /connect (null when BL has not
// issued an activation key, in which case only the User-Key field shows).
const [activationUrl, setActivationUrl] = useState<string | null>(null)
const [authType, setAuthType] = useState<'oauth' | 'token' | null>(null)
// Preview state
@@ -2412,6 +2445,13 @@ export default function ArcimMigrationWorkspace({
setStep('connect')
setIsLoading(true)
setError(null)
// Drop the previous attempt's consent and one-time URLs before asking for
// new ones: if /connect fails, the step must not keep offering a stale
// activation link that completes the earlier consent.
setConsentId(null)
setAuthType(null)
setAuthUrl(null)
setActivationUrl(null)
try {
const res = await fetch('/api/extensions/ext/arcim-migration/connect', {
@@ -2428,6 +2468,7 @@ export default function ArcimMigrationWorkspace({
const data = await res.json()
setConsentId(data.consentId)
setAuthType(data.authType)
setActivationUrl(typeof data.activationUrl === 'string' ? data.activationUrl : null)
if (data.alreadyConnected) {
// Existing connection: skip auth, go straight to preview
@@ -2556,6 +2597,7 @@ export default function ArcimMigrationWorkspace({
popup?.close()
if (data.authType === 'token') {
// Re-enter credentials for token-based providers
setActivationUrl(typeof data.activationUrl === 'string' ? data.activationUrl : null)
setStep('connect')
}
}
@@ -3203,6 +3245,7 @@ export default function ArcimMigrationWorkspace({
isLoading={isLoading}
error={error}
authUrl={authUrl}
activationUrl={activationUrl}
consentId={consentId}
onTokenSubmit={handleTokenSubmit}
onBack={() => {