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:
@@ -51,6 +51,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fallback: unknown = null;
|
let fallback: unknown = null;
|
||||||
|
let forbiddenCount = 0;
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
try {
|
try {
|
||||||
const detail = (await gisApi.parcela.get(c.id)) as {
|
const detail = (await gisApi.parcela.get(c.id)) as {
|
||||||
@@ -70,8 +71,13 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json(detail);
|
return NextResponse.json(detail);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Skip candidates that 403 (scope) or fail individually.
|
// Skip candidates that 403 (scope) or fail individually,
|
||||||
if (e instanceof GisApiError && e.status === 403) continue;
|
// 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;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,6 +92,31 @@ export async function GET(request: Request) {
|
|||||||
);
|
);
|
||||||
return NextResponse.json(fallback);
|
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 });
|
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GisApiError) {
|
if (err instanceof GisApiError) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
import {
|
import {
|
||||||
X, RefreshCw, Loader2, FileText, Download, AlertCircle,
|
X, RefreshCw, Loader2, FileText, Download, AlertCircle,
|
||||||
Home, Building, MapPin, ChevronDown, ChevronRight, Users,
|
Home, Building, MapPin, ChevronDown, ChevronRight, Users, LogIn,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
@@ -409,9 +410,25 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error === "forbidden" && (
|
{error === "forbidden" && (
|
||||||
<div className="m-3 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-50 p-2 text-xs text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
<div className="m-3 flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-50 p-2 text-xs text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
<span>Nu ai permisiuni de citire detaliată (scope insuficient).</span>
|
<span>
|
||||||
|
Token-ul tău nu poartă scope-ul „enrichment" (gis-api a
|
||||||
|
răspuns 403 pentru toate candidate-le). Re-loghează-te ca
|
||||||
|
să primești un access_token cu scope-ul corect.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
signIn("authentik", { callbackUrl: window.location.href })
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 self-start rounded bg-amber-200 px-2 py-1 text-xs font-medium text-amber-950 hover:bg-amber-300 dark:bg-amber-800 dark:text-amber-50 dark:hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
<LogIn className="h-3 w-3" />
|
||||||
|
Re-loghează-te
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && error !== "forbidden" && (
|
{error && error !== "forbidden" && (
|
||||||
|
|||||||
Reference in New Issue
Block a user