diff --git a/src/api/viewService.ts b/src/api/viewService.ts index 91752be..3101153 100644 --- a/src/api/viewService.ts +++ b/src/api/viewService.ts @@ -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(res: Response): Promise { 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; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 352157e..75187b1 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -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(screenIdParam); const [detailTitle, setDetailTitle] = useState(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 ( +
+
+ +

+ {denied ? "You don't have access" : "Couldn't load the app"} +

+

+ {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."} +

+ +
+
+ ); + } + if (!navReady && !screenIdParam && !instanceIdParam) { return (
diff --git a/src/hooks/useHeaderConfig.ts b/src/hooks/useHeaderConfig.ts index b22b81c..2a250a4 100644 --- a/src/hooks/useHeaderConfig.ts +++ b/src/hooks/useHeaderConfig.ts @@ -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 ?? [];