Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/smart-stamps-hope.md
Original file line number Diff line number Diff line change
@@ -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.
144 changes: 98 additions & 46 deletions fdm-app/app/components/custom/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-background flex min-h-screen items-center justify-center px-4">
<div className="motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 flex max-w-md flex-col items-center text-center motion-safe:duration-500">
<div className="mb-6 flex aspect-square size-16 items-center justify-center rounded-2xl bg-[#122023]">
<Compass className="size-8 text-white" strokeWidth={1.75} />
</div>

<h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
Deze pagina is niet beschikbaar
</h1>
<p className="text-muted-foreground mt-4 text-lg text-pretty">
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.
</p>

<div className="mt-8 flex w-full flex-col items-center gap-4">
<Button
size="lg"
className="w-full sm:w-auto"
onClick={() => {
if (window.history.length > 1) {
void navigate(-1)
} else {
void navigate("/")
}
}}
>
<ArrowLeft className="mr-2 size-4" /> Terug naar vorige pagina
</Button>
<div className="flex items-center gap-6">
<Button variant="link" className="text-muted-foreground h-auto p-0" asChild>
<NavLink to="/farm">
<Home className="mr-1.5 size-3.5" /> Mijn bedrijven
</NavLink>
</Button>
<Button variant="link" className="text-muted-foreground h-auto p-0" asChild>
<NavLink to="/support/new">
<LifeBuoy className="mr-1.5 size-3.5" /> Ondersteuning
</NavLink>
</Button>
</div>
</div>
</div>
</div>
)
}

/**
* Displays a full-screen error block with tailored messaging and navigation options.
*
* Depending on the provided error status, this component renders:
* - A specific message and navigation buttons for a 404 error, indicating that the page does not exist.
* - A generic error message along with a button to copy the formatted error details (including status, message, stack trace, page, and timestamp) to the clipboard for other errors.
* - One unified, generic "page unavailable" message for client error statuses (400, 403, 404),
* via {@link ClientErrorPage}. This deliberately never reveals whether a specific resource
* exists or the user simply lacks permission for it — both cases look identical to the user.
* - A visually distinct, diagnostic error message for other (server/unexpected) errors, along
* with a button to copy the formatted error details (including status, message, stack trace,
* page, and timestamp) to the clipboard.
*
* If an error message is available, the component also displays the error details formatted as pretty-printed JSON. Otherwise, it shows a fallback message for non-404 errors.
* If an error message is available, the component also displays the error details formatted as
* pretty-printed JSON for the non-client-error case.
*
* @param status - HTTP status code of the error or null.
* @param message - Detailed error message, or null if not available.
Expand All @@ -35,7 +103,6 @@ export function ErrorBlock({
timestamp: string
}) {
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle")
const navigate = useNavigate()

useEffect(() => {
if (clientConfig.analytics.sentry) {
Expand All @@ -54,6 +121,12 @@ export function ErrorBlock({
}
}, [copyState])

const isClientError = status !== null && CLIENT_ERROR_STATUSES.includes(status)

if (isClientError) {
return <ClientErrorPage />
}

const errorDetails = JSON.stringify(
{
status,
Expand Down Expand Up @@ -81,6 +154,7 @@ export function ErrorBlock({
setCopyState("failed")
}
}

return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 px-4 dark:bg-gray-900">
<div className="mb-8 w-full max-w-md overflow-hidden rounded-lg">
Expand All @@ -91,50 +165,28 @@ export function ErrorBlock({
/>
</div>
<h1 className="mb-2 text-4xl font-bold text-gray-900 dark:text-gray-100">
{status === 404 ? "Aii, deze pagina bestaat niet." : "Oeps, er lijkt iets mis te zijn."}
Oeps, er lijkt iets mis te zijn.
</h1>
<p className="mb-8 text-center text-xl text-gray-600 dark:text-gray-400">
{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.
</p>

{status === 404 ? (
<div className="flex flex-col gap-4 sm:flex-row">
<Button
onClick={() => {
if (window.history.length > 1) {
void navigate(-1)
} else {
void navigate("/")
}
}}
>
<ArrowLeft className="mr-2 h-4 w-4" /> Terug naar vorige pagina
</Button>
<Button variant="outline" asChild>
<NavLink to="/">
<Home className="mr-2 h-4 w-4" /> Terug naar de hoofdpagina
</NavLink>
</Button>
</div>
) : (
<div className="flex flex-col gap-4 sm:flex-row">
<Button asChild>
<NavLink to="/">
<Home className="mr-2 h-4 w-4" /> Terug naar de hoofdpagina
</NavLink>
</Button>
<Button variant="outline" onClick={copyStackTrace}>
<Copy className="mr-2 h-4 w-4" />
{copyState === "copied"
? "Gekopieerd!"
: copyState === "failed"
? "Browser staat niet toe om te kopiëren — selecteer de tekst hieronder en kopieer handmatig"
: "Kopieer foutmelding"}
</Button>
</div>
)}
<div className="flex flex-col gap-4 sm:flex-row">
<Button asChild>
<NavLink to="/">
<Home className="mr-2 h-4 w-4" /> Terug naar de hoofdpagina
</NavLink>
</Button>
<Button variant="outline" onClick={copyStackTrace}>
<Copy className="mr-2 h-4 w-4" />
{copyState === "copied"
? "Gekopieerd!"
: copyState === "failed"
? "Browser staat niet toe om te kopiëren — selecteer de tekst hieronder en kopieer handmatig"
: "Kopieer foutmelding"}
</Button>
</div>
{message ? (
<div className="mt-8 w-full max-w-2xl">
<h2 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
Expand All @@ -144,7 +196,7 @@ export function ErrorBlock({
{errorDetails}
</pre>
</div>
) : status === 404 ? null : (
) : (
<p className="mt-8 text-gray-600 dark:text-gray-400">
Er zijn helaas geen details over de fout beschikbaar.
</p>
Expand Down
12 changes: 9 additions & 3 deletions fdm-app/app/lib/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -267,7 +273,7 @@ export function handleActionError(error: unknown) {
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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(
{
Expand Down
15 changes: 8 additions & 7 deletions fdm-app/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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 (
<ErrorBlock
status={404} // Show 404 in case user is not authorized to access page
message={error.statusText}
stacktrace={error.data}
status={error.status}
message={null}
stacktrace={null}
page={page}
timestamp={timestamp}
/>
Expand Down
Loading
Loading