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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
8aa5d54510
commit
a769bc9d03
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user