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:
Bhanu Prakash Sai Potteri 2026-08-19 13:15:26 +05:30
parent d4c55eeec3
commit 63ac64fffe
3 changed files with 49 additions and 6 deletions

View File

@ -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>;

View File

@ -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">

View File

@ -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 ?? [];