fix(geoportal-v2): silent auto re-grant on scope-missing 403

Removes the "Re-loghează-te" button + scope-mismatch warning prose.
On 403 from /api/gis/parcela/find the panel now:

1. Checks sessionStorage flag — false on first 403 of the tab
2. Sets the flag, fires signIn("authentik", { callbackUrl: current
   URL }) silently. For an SSO'd user this is a sub-second Authentik
   redirect cycle that mints a fresh access_token with the right
   scope claims, lands the user back on the same panel, and the
   re-mount fetches successfully — no visible message, no prompt.
3. If another 403 happens after the retry (i.e., Authentik genuinely
   can't grant the scope — config issue, not a stale-token issue),
   falls through to a discreet "Datele detaliate nu pot fi încărcate
   momentan." note. No call-to-action, no jargon.
4. On any successful 200 fetch, clears the sessionStorage flag so a
   future 403 in the same tab can re-trigger the silent retry.

Per Marius: "vreau doar să meargă, safe și fix" — no auth-flow
chrome shown to the user. The recovery is part of the system's
correctness contract, not a feature for the user to manage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-19 16:57:42 +03:00
parent 71df1ee9ec
commit a23ba1957f
+40 -21
View File
@@ -1,13 +1,20 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { signIn } from "next-auth/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, LogIn, Home, Building, MapPin, ChevronDown, ChevronRight, Users,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/shared/lib/utils"; import { cn } from "@/shared/lib/utils";
// Once-per-tab marker: when we trigger a silent re-grant via signIn() the
// page reloads through Authentik. After the round-trip we land back on the
// same panel; we don't want to retry the re-grant in an infinite loop if
// Authentik actually can't extend the scope, so we set a sessionStorage
// flag before redirecting and check it on mount.
const AUTH_RETRY_KEY = "gis_panel_auth_retry";
export interface ClickedFeatureLite { export interface ClickedFeatureLite {
/** GisFeature uuid — typically empty from PMTiles overview, falls back to search-by-cadref */ /** GisFeature uuid — typically empty from PMTiles overview, falls back to search-by-cadref */
id: string; id: string;
@@ -152,6 +159,13 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
const [condoOwners, setCondoOwners] = useState<CondoOwner[] | null>(null); const [condoOwners, setCondoOwners] = useState<CondoOwner[] | null>(null);
const [condoLoading, setCondoLoading] = useState(false); const [condoLoading, setCondoLoading] = useState(false);
const [condoExpanded, setCondoExpanded] = useState(false); const [condoExpanded, setCondoExpanded] = useState(false);
// Tracks whether we've already triggered a silent re-grant for this tab,
// so a real Authentik scope misconfig falls through to the muted error
// state instead of cycling forever through signIn().
const authRetriedRef = useRef<boolean>(
typeof window !== "undefined" &&
sessionStorage.getItem(AUTH_RETRY_KEY) === "1",
);
const isCladiri = feature.layerId === "CLADIRI_ACTIVE"; const isCladiri = feature.layerId === "CLADIRI_ACTIVE";
@@ -186,10 +200,28 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
return; return;
} }
if (r.status === 403) { if (r.status === 403) {
// Token reaches gis-api but lacks enrichment_scope. Trigger a
// silent fresh OIDC grant — for SSO'd users Authentik is a
// sub-second redirect, the panel re-mounts with full scope and
// shows real data. authRetriedRef guards against infinite loop
// if Authentik genuinely can't grant the scope.
if (!authRetriedRef.current && typeof window !== "undefined") {
authRetriedRef.current = true;
sessionStorage.setItem(AUTH_RETRY_KEY, "1");
void signIn("authentik", { callbackUrl: window.location.href });
// Keep "loading" while the browser navigates — no message
// needed; the redirect itself is the feedback.
return;
}
setError("forbidden"); setError("forbidden");
setLoading(false); setLoading(false);
return; return;
} }
// Successful fetch (200) — clear the retry marker so a future 403
// can re-trigger silently.
if (typeof window !== "undefined") {
sessionStorage.removeItem(AUTH_RETRY_KEY);
}
if (!r.ok) { if (!r.ok) {
setError("fetch_failed"); setError("fetch_failed");
setLoading(false); setLoading(false);
@@ -409,26 +441,13 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
</div> </div>
)} )}
{/* Forbidden after a silent re-grant attempt already failed
once — likely Authentik scope misconfig. Stays discreet (no
call-to-action), only diag info for the operator. */}
{error === "forbidden" && ( {error === "forbidden" && (
<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="m-3 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-50/50 p-2 text-xs text-amber-900 dark:bg-amber-950/20 dark:text-amber-200">
<div className="flex items-start gap-2"> <AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" /> <span>Datele detaliate nu pot fi încărcate momentan.</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" && (