fix: show access-denied message instead of infinite loader on 403
Header/nav config 403 (signed in but not entitled to this app) left the shell spinning forever. Attach HTTP status to thrown errors (ApiError), stop retrying a 403, and render a clear access-denied panel with a sign-in-as-different-account action.
This commit is contained in:
parent
d4c55eeec3
commit
63ac64fffe
@ -2,6 +2,17 @@ import { APP_ID, BASE_URL } from "../config";
|
||||
|
||||
const TOKEN_KEY = "p2p_token";
|
||||
|
||||
// Error carrying the HTTP status so callers can distinguish e.g. a 403
|
||||
// (authenticated but not entitled to this app) from a generic failure.
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function baseUrl(): string {
|
||||
return BASE_URL.replace(/\/+$/, "");
|
||||
}
|
||||
@ -20,12 +31,12 @@ async function parseResponse<T>(res: Response): Promise<T> {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem("p2p_user");
|
||||
window.location.href = `${import.meta.env.BASE_URL}login`;
|
||||
throw new Error("Unauthorized");
|
||||
throw new ApiError(401, "Unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try { const e = await res.json(); if (e.error) msg = e.error; } catch {}
|
||||
throw new Error(msg);
|
||||
throw new ApiError(res.status, msg);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Suspense, lazy, useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Loader2, Sparkles } from "lucide-react";
|
||||
import { ChevronRight, Loader2, ShieldAlert, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@ -14,8 +14,9 @@ import FormModal from "./FormModal";
|
||||
import CommandPalette from "./CommandPalette";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
import { TableSkeleton } from "./Skeleton";
|
||||
import { getScreen, getInstance, type LayoutElement } from "@/api/viewService";
|
||||
import { getScreen, getInstance, ApiError, type LayoutElement } from "@/api/viewService";
|
||||
import { useHeaderConfig } from "@/hooks/useHeaderConfig";
|
||||
import { useAuthContext } from "@/hooks/AuthContext";
|
||||
import { useSidebar } from "@/hooks/useSidebar";
|
||||
import { actionsForStateId, stateById } from "@/lib/workflow";
|
||||
import {
|
||||
@ -57,7 +58,8 @@ export default function AppShell() {
|
||||
const instanceIdParam = params.instanceId ?? null;
|
||||
|
||||
const sidebar = useSidebar();
|
||||
const { navItems, isSuccess: navReady } = useHeaderConfig();
|
||||
const { logout } = useAuthContext();
|
||||
const { navItems, isSuccess: navReady, isError: navError, error: navErr } = useHeaderConfig();
|
||||
|
||||
const [activeScreenId, setActiveScreenId] = useState<string | null>(screenIdParam);
|
||||
const [detailTitle, setDetailTitle] = useState<string | null>(null);
|
||||
@ -141,6 +143,33 @@ export default function AppShell() {
|
||||
(activeScreenId ? RDBMS_SCREEN_LABELS[activeScreenId] : undefined) ??
|
||||
"Invoices";
|
||||
|
||||
// The nav config failing means the whole app can't render — most commonly a
|
||||
// 403 for a user who is signed in but not entitled to this app. Show a clear
|
||||
// message with a way out instead of a loader that never resolves.
|
||||
if (navError) {
|
||||
const denied = navErr instanceof ApiError && navErr.status === 403;
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div className="flex max-w-sm flex-col items-center gap-3 text-center">
|
||||
<ShieldAlert className="size-8 text-destructive" aria-hidden />
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
{denied ? "You don't have access" : "Couldn't load the app"}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{denied
|
||||
? "Your account isn't authorised for this application. Sign in with an account that has access, or contact your administrator."
|
||||
: navErr instanceof Error
|
||||
? navErr.message
|
||||
: "Something went wrong while loading the app."}
|
||||
</p>
|
||||
<Button className="mt-2" onClick={logout}>
|
||||
Sign in with a different account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!navReady && !screenIdParam && !instanceIdParam) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
|
||||
@ -5,13 +5,16 @@
|
||||
// query now serves both.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getHeaderConfig, type NavItem } from "../api/viewService";
|
||||
import { getHeaderConfig, ApiError, type NavItem } from "../api/viewService";
|
||||
|
||||
export function useHeaderConfig() {
|
||||
const query = useQuery({
|
||||
queryKey: ["header-config", "desktop"],
|
||||
staleTime: 5 * 60_000,
|
||||
queryFn: () => getHeaderConfig("desktop"),
|
||||
// A 403 (no access to this app) is terminal — don't retry, fail fast so
|
||||
// the UI can show an access-denied message instead of a hanging loader.
|
||||
retry: (count, err) => !(err instanceof ApiError && err.status === 403) && count < 2,
|
||||
});
|
||||
|
||||
const navItems: NavItem[] = query.data?.components?.nav?.items ?? [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user