fix(geoportal-v2): surface scope-insufficient instead of silent 404

The other-session's gis-api investigation found that gis-api is
working correctly — full/basic/none scopes all behave per spec.
The bug was in our /api/gis/parcela/find proxy: when EVERY candidate
returned 403 from gis-api (because the caller's JWT carried no
enrichment_scope claim), the proxy swallowed the 403s and returned
silent 404. The panel then rendered the "not in central DB" empty
state instead of prompting re-login.

This was the case for Marius today — his pre-refresh-fix session
held a token without the enrichment claim. After the auth self-heal
fix (commit 8ff67d1) the next gis-api call would have re-authed
correctly, but the panel never gave him that signal because find
hid the 403.

Fix in two places:

1. /api/gis/parcela/find:
   - Count 403s seen during candidate iteration
   - If forbiddenCount > 0 && forbiddenCount === candidates.length,
     return 403 { error: "scope_insufficient", ... } with a log line
     [gis-parcela-find] all_candidates_forbidden siruta=X cad=Y N
   - Otherwise log [gis-parcela-find] no_match (so we never go silent)

2. feature-info-panel: when fetch returns 403, the existing
   "forbidden" UI was a passive warning. Now it shows an actionable
   "Re-loghează-te" button that fires signIn("authentik", {
   callbackUrl: current }) — same path SessionErrorWatcher uses for
   RefreshAccessTokenError.

Reference: gis-api session report 2026-05-19 (Marius forwarded
analysis); the gis-api repo is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-19 16:52:47 +03:00
parent 8ff67d19fb
commit 71df1ee9ec
2 changed files with 54 additions and 6 deletions
+33 -2
View File
@@ -51,6 +51,7 @@ export async function GET(request: Request) {
}
let fallback: unknown = null;
let forbiddenCount = 0;
for (const c of candidates) {
try {
const detail = (await gisApi.parcela.get(c.id)) as {
@@ -70,8 +71,13 @@ export async function GET(request: Request) {
return NextResponse.json(detail);
}
} catch (e) {
// Skip candidates that 403 (scope) or fail individually.
if (e instanceof GisApiError && e.status === 403) continue;
// Skip candidates that 403 (scope) or fail individually,
// but track 403s so we can distinguish "no data" from
// "all data was scope-blocked" at the route boundary.
if (e instanceof GisApiError && e.status === 403) {
forbiddenCount++;
continue;
}
throw e;
}
}
@@ -86,6 +92,31 @@ export async function GET(request: Request) {
);
return NextResponse.json(fallback);
}
// No readable candidate AND every read returned 403 → user is
// authenticated but lacks the enrichment scope. Surface this
// explicitly so the panel can prompt re-login instead of showing
// an empty/404 state (the older behaviour was silent 404 — caller
// couldn't tell auth-loss from missing-data).
if (forbiddenCount > 0 && forbiddenCount === candidates.length) {
console.warn(
"[gis-parcela-find] all_candidates_forbidden siruta=%s cad=%s candidates=%d",
siruta,
cad,
candidates.length,
);
return NextResponse.json(
{
error: "scope_insufficient",
hint:
"All candidates returned 403 from gis-api — token likely lacks enrichment_scope. Sign out and back in.",
candidates: candidates.length,
},
{ status: 403 },
);
}
console.log("[gis-parcela-find] no_match siruta=%s cad=%s", siruta, cad);
return NextResponse.json({ error: "not_found" }, { status: 404 });
} catch (err) {
if (err instanceof GisApiError) {