From 7ff7074c083fd67d1d2177ceb18ac51d97d11d9c Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:29:06 +0200 Subject: [PATCH 1/9] feat: show a visually different page in case of client errors at loader instead of the generic error page --- .changeset/smart-stamps-hope.md | 5 + fdm-app/app/components/custom/error.tsx | 144 +++++++++++----- fdm-app/app/lib/error.ts | 12 +- fdm-app/app/root.tsx | 15 +- fdm-app/app/routes/farm.tsx | 216 +++++++++++++++++------- fdm-app/app/routes/organization.tsx | 102 +++++++---- fdm-app/app/routes/support.tsx | 76 ++++++--- fdm-app/app/routes/user.tsx | 78 ++++++--- 8 files changed, 454 insertions(+), 194 deletions(-) create mode 100644 .changeset/smart-stamps-hope.md diff --git a/.changeset/smart-stamps-hope.md b/.changeset/smart-stamps-hope.md new file mode 100644 index 000000000..eb26398a4 --- /dev/null +++ b/.changeset/smart-stamps-hope.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-app": minor +--- + +In case of a client error (400, 403 and 404) show a visually distinct page than for an error (500) to make it more clear to the user. diff --git a/fdm-app/app/components/custom/error.tsx b/fdm-app/app/components/custom/error.tsx index 875fe01cb..df28eaf1e 100644 --- a/fdm-app/app/components/custom/error.tsx +++ b/fdm-app/app/components/custom/error.tsx @@ -1,19 +1,87 @@ import * as Sentry from "@sentry/react-router" -import { ArrowLeft, Copy, Home } from "lucide-react" +import { ArrowLeft, Compass, Copy, Home, LifeBuoy } from "lucide-react" import { useEffect, useState } from "react" import { NavLink, useNavigate } from "react-router" import { Button } from "~/components/ui/button" import { clientConfig } from "~/lib/config" import { normalizePage } from "~/lib/url-utils" +export const CLIENT_ERROR_STATUSES = [400, 403, 404] + +/** + * Full-screen, generic "page unavailable" state for client error statuses (400, 403, 404). + * + * Deliberately visually distinct from the 5xx/unexpected-error UI below (no illustration, no + * grey background, no stack trace) — this is an expected, everyday state a user might land on by + * accident, not a bug. It borrows the brand-mark treatment already used on the sign-in/auth + * surfaces (a solid `#122023` badge) to give the moment real presence, with a short reassuring + * message and a clearly prioritized set of next steps, none of which reveal whether the specific + * page/resource exists or the user simply lacks permission for it. + */ +export function ClientErrorPage() { + const navigate = useNavigate() + + return ( +
+ Het lijkt erop dat deze pagina niet bestaat, of dat je er geen toegang tot hebt. + Controleer het adres en of je toegang hebt, of neem contact op als je denkt dat dit niet + klopt. +
+ +- {status === 404 - ? "Het lijkt erop dat de pagina die je zoekt niet bestaat." - : "Er is onverwachts wat fout gegaan. Probeer eerst opnieuw. Als het niet opnieuw lukt, kopieer dan de foutmelding en neem contact op met Ondersteuning."} + Er is onverwachts wat fout gegaan. Probeer eerst opnieuw. Als het niet opnieuw lukt, kopieer + dan de foutmelding en neem contact op met Ondersteuning.
- {status === 404 ? ( -Er zijn helaas geen details over de fout beschikbaar.
diff --git a/fdm-app/app/lib/error.ts b/fdm-app/app/lib/error.ts index c1606d7d7..1d9208c47 100644 --- a/fdm-app/app/lib/error.ts +++ b/fdm-app/app/lib/error.ts @@ -8,6 +8,12 @@ const errorIdSize = 8 // Number of characters in ID export const createErrorId = customAlphabet(customErrorAlphabet, errorIdSize) +// Thrown by fdm-core's checkPermission for any resource the principal can't access, whether it +// exists or not. Shared here so route loaders that need to distinguish this specific failure +// (e.g. to keep the app shell up and show a friendly in-app message instead of throwing) don't +// duplicate the string. +export const PERMISSION_DENIED_MESSAGE = "Principal does not have permission to perform this action" + /** * Extracts a human-readable error message from any thrown value. * @@ -120,7 +126,7 @@ export function handleLoaderError(error: unknown) { } // Permission denied error - if (containsErrorMessage(error, "Principal does not have permission to perform this action")) { + if (containsErrorMessage(error, PERMISSION_DENIED_MESSAGE)) { console.warn("Permission denied: ", error) return data( { @@ -183,7 +189,7 @@ export function handleLoaderError(error: unknown) { * Recursively checks whether an error or any error in its cause chain * contains the given substring in its message. */ -function containsErrorMessage(error: unknown, message: string): boolean { +export function containsErrorMessage(error: unknown, message: string): boolean { if (!(error instanceof Error)) return false if (error.message.includes(message)) return true return containsErrorMessage(error.cause, message) @@ -267,7 +273,7 @@ export function handleActionError(error: unknown) { } // Permission denied error - if (containsErrorMessage(error, "Principal does not have permission to perform this action")) { + if (containsErrorMessage(error, PERMISSION_DENIED_MESSAGE)) { console.warn("Permission denied: ", error) return dataWithWarning( { diff --git a/fdm-app/app/root.tsx b/fdm-app/app/root.tsx index 1aaa770e3..43072ff8e 100644 --- a/fdm-app/app/root.tsx +++ b/fdm-app/app/root.tsx @@ -19,7 +19,7 @@ import { import { getToast } from "remix-toast" import { toast as notify } from "sonner" import { Banner } from "~/components/custom/banner" -import { ErrorBlock } from "~/components/custom/error" +import { CLIENT_ERROR_STATUSES, ErrorBlock } from "~/components/custom/error" import { NavigationProgress } from "~/components/custom/navigation-progress" import { Toaster } from "~/components/ui/sonner" import { auth } from "~/lib/auth.server" @@ -216,7 +216,9 @@ export default function App() { * This component distinguishes between route error responses and generic errors: * - For route errors: * - Redirects to the signin page if the error status is 401. - * - Renders a 404 error block for client errors with status 400, 403, or 404. + * - Renders one unified, generic "page unavailable" error block for client errors with status + * 400, 403, or 404 — deliberately without the specific message/data, so a user can never tell + * whether a resource doesn't exist or they simply lack permission for it. * - Logs other route errors to the error tracking service and renders an error block reflecting the specific status. * - For generic Error instances, it logs the error and renders a 500 error block with the error message and stack trace. * - If the error is null, no error UI is rendered. @@ -240,13 +242,12 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { throw redirect(signInUrl) } - const clientErrors = [400, 403, 404] - if (clientErrors.includes(error.status)) { + if (CLIENT_ERROR_STATUSES.includes(error.status)) { return (Het lijkt erop dat deze pagina niet bestaat, of dat je er geen toegang tot hebt. - Controleer het adres en of je toegang hebt, of neem contact op als je denkt dat dit niet + Controleer het webadres en of je toegang hebt, of neem contact op als je denkt dat dit niet klopt.
@@ -263,17 +263,29 @@ export function ErrorBlock({ */ export function RouteErrorFallback({ error }: { error: unknown }) { const location = useLocation() + const navigate = useNavigate() const page = location.pathname const timestamp = new Date().toISOString() - if (isRouteErrorResponse(error)) { - // Redirect to signin page if authentication is not provided - if (error.status === 401) { + const isUnauthorized = isRouteErrorResponse(error) && error.status === 401 + + // Redirect to signin page if authentication is not provided. Navigation must happen after + // render (in an effect), not by throwing `redirect()` here — this component renders inside an + // `ErrorBoundary`, not a loader/action, so a thrown redirect isn't a supported navigation + // mechanism in that position. + useEffect(() => { + if (isUnauthorized) { const currentPath = location.pathname + location.search + location.hash const signInUrl = `./signin?redirectTo=${encodeURIComponent(currentPath)}` - throw redirect(signInUrl) + void navigate(signInUrl, { replace: true }) } + }, [isUnauthorized, location, navigate]) + if (isUnauthorized) { + return null + } + + if (isRouteErrorResponse(error)) { if (CLIENT_ERROR_STATUSES.includes(error.status)) { return