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:
@@ -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" && (
|
||||||
|
|||||||
Reference in New Issue
Block a user