fix(providers): stop the Fortnox reconnect loop, and make the attachment scopes opt-in (#1761)
The Fortnox document import needs the archive and connectfile scopes, which the registered Fortnox app does not have. Since #1549 pulled them out of the connect request (they broke every connect with invalid_scope before login), every attachment call fails and the user was told "Koppla om Fortnox och godkann behorigheterna", under a button that reruns an authorize URL still not asking for those scopes. Klura AB followed that loop four times and bought the Fortnox Arkiv module trying to satisfy it. Prod evidence: no Fortnox attachment has ever imported, across 166 companies and 24 consents since the feature shipped, and no live token carries the scopes. The error and the scope list now derive from one flag, FORTNOX_DOCUMENT_SCOPES_APPROVED. While it is false a permission failure maps to a new PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, which says the permission is missing on our side, that reconnecting will not help, and that the rest of the migration came through; the card offers no button, because no user action can succeed. The attachment scopes also become an opt-in consent rather than part of every connect. Fortnox derives customer licence requirements from what an integration requests, so asking everyone for Arkivplats would put a licence in front of customers who never import a receipt; and keeping it off the default connect caps the blast radius of a wrong portal registration at the underlag flow rather than every Fortnox connection. buildFortnoxAuthUrl already took per-call scopes, provider-client simply never passed any, so this threads documentScopes from that one button through /connect into the authorize URL. A document consent is always a superset of an ordinary one: the callback overwrites the consent's tokens in place, so a narrower grant would revoke the migration's own ledger access. Pinned by a test that holds either way the flag is set, alongside one for the 400-with-behorighet answer that six companies hit between 08-13 and 08-19 and saw only a generic retry for. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
c5b7716f74
commit
f3e4fdcf32
@@ -35,6 +35,7 @@ import {
|
||||
arcimDocumentImportReducer,
|
||||
documentOAuthProblemFromReason,
|
||||
parseArcimDocumentOAuthResume,
|
||||
PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE,
|
||||
requestArcimDocumentImport,
|
||||
resolveArcimDocumentFollowUpProvider,
|
||||
watchArcimOAuthPopup,
|
||||
@@ -1586,11 +1587,17 @@ function DocumentImportFollowUp({
|
||||
|
||||
const reconnectRequired = state.problem?.reconnectRequired === true
|
||||
const discoveryFailed = state.phase === 'discovery-error'
|
||||
// Fortnox has not granted the file permissions to the integration itself, so
|
||||
// neither reconnecting nor retrying can succeed: state it and offer nothing.
|
||||
const scopesUnavailable =
|
||||
state.problem?.code === PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE
|
||||
return (
|
||||
<section className="space-y-3" aria-live="polite">
|
||||
{title}
|
||||
<p className="text-sm text-destructive">
|
||||
{state.problem?.message
|
||||
{scopesUnavailable
|
||||
? t('ext_arcim_documents_scope_unavailable')
|
||||
: state.problem?.message
|
||||
? state.problem.message
|
||||
: reconnectRequired
|
||||
? t('ext_arcim_documents_scope_error')
|
||||
@@ -1612,21 +1619,23 @@ function DocumentImportFollowUp({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11"
|
||||
onClick={reconnectRequired ? onReconnect : discoveryFailed ? onDiscover : onImport}
|
||||
>
|
||||
{reconnectRequired ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{reconnectRequired
|
||||
? t('ext_arcim_documents_reconnect_action')
|
||||
: discoveryFailed
|
||||
? t('ext_arcim_documents_retry_discovery')
|
||||
: t('ext_arcim_documents_retry_import')}
|
||||
</Button>
|
||||
{!scopesUnavailable && (
|
||||
<Button
|
||||
className="min-h-11"
|
||||
onClick={reconnectRequired ? onReconnect : discoveryFailed ? onDiscover : onImport}
|
||||
>
|
||||
{reconnectRequired ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{reconnectRequired
|
||||
? t('ext_arcim_documents_reconnect_action')
|
||||
: discoveryFailed
|
||||
? t('ext_arcim_documents_retry_discovery')
|
||||
: t('ext_arcim_documents_retry_import')}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -2192,7 +2201,7 @@ export default function ArcimMigrationWorkspace({
|
||||
const handleReconnect = useCallback(async (
|
||||
provider: ArcimProvider,
|
||||
existingConsentId: string,
|
||||
options?: { onFailure?: () => void },
|
||||
options?: { onFailure?: () => void; documentScopes?: boolean },
|
||||
) => {
|
||||
setError(null)
|
||||
setAuthExpired(false)
|
||||
@@ -2215,7 +2224,11 @@ export default function ArcimMigrationWorkspace({
|
||||
const res = await fetch('/api/extensions/ext/arcim-migration/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider, reconnect: true }),
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
reconnect: true,
|
||||
documentScopes: options?.documentScopes === true,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
@@ -2328,6 +2341,9 @@ export default function ArcimMigrationWorkspace({
|
||||
storeDocumentOAuthResume(reconnectAction)
|
||||
dispatchDocumentImport({ type: 'reconnect-started' })
|
||||
void handleReconnect('fortnox', consentId, {
|
||||
// The whole point of this reconnect is the attachment permissions, so
|
||||
// this is the one path that asks Fortnox for them.
|
||||
documentScopes: true,
|
||||
onFailure: () => {
|
||||
dispatchDocumentImport(
|
||||
reconnectAction === 'discover'
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ARCIM_DOCUMENT_OAUTH_RESUME_KEY,
|
||||
INITIAL_ARCIM_DOCUMENT_IMPORT_STATE,
|
||||
PROVIDER_DOCUMENT_SCOPES_REQUIRED,
|
||||
PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE,
|
||||
ArcimDocumentImportRequestError,
|
||||
arcimDocumentImportReducer,
|
||||
documentOAuthProblemFromReason,
|
||||
@@ -168,6 +169,36 @@ describe('Fortnox document follow-up state', () => {
|
||||
reconnectRequired: true,
|
||||
})
|
||||
})
|
||||
|
||||
// The scopes are missing from the connect request itself, so offering a
|
||||
// reconnect would loop the user forever (Klura AB, 2026-08-20).
|
||||
it('never offers a reconnect when the scopes are unavailable to the integration', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE,
|
||||
message: 'scopes unavailable',
|
||||
requestId: 'req_unavailable',
|
||||
},
|
||||
}),
|
||||
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
|
||||
const error = await requestArcimDocumentImport(
|
||||
'consent-1',
|
||||
true,
|
||||
fetcher,
|
||||
).catch((caught) => caught)
|
||||
|
||||
expect(error).toBeInstanceOf(ArcimDocumentImportRequestError)
|
||||
expect(error.problem).toEqual({
|
||||
code: PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE,
|
||||
requestId: 'req_unavailable',
|
||||
reconnectRequired: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('document import endpoint request', () => {
|
||||
|
||||
@@ -4,6 +4,13 @@ export const ARCIM_DOCUMENT_IMPORT_ENDPOINT =
|
||||
export const PROVIDER_DOCUMENT_SCOPES_REQUIRED =
|
||||
'PROVIDER_DOCUMENT_SCOPES_REQUIRED'
|
||||
|
||||
/**
|
||||
* The connect request does not ask Fortnox for Arkiv and Koppla fil at all,
|
||||
* so no reconnect can grant them: the error offers no action, only the truth.
|
||||
*/
|
||||
export const PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE =
|
||||
'PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE'
|
||||
|
||||
export const ARCIM_DOCUMENT_OAUTH_RESUME_KEY =
|
||||
'arcim-document-oauth-resume'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user