Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ APP_DEBUG=true
APP_URL=http://localhost:8000

# 3AG Accounts OIDC (identity provider)
ACCOUNTS_URL=http://127.0.0.1:8000
ACCOUNTS_URL=https://accounts.test
ACCOUNTS_CLIENT_ID=
ACCOUNTS_CLIENT_SECRET=
ACCOUNTS_REDIRECT_URI="${APP_URL}/auth/accounts/callback"
Expand Down
41 changes: 41 additions & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;

return Application::configure(basePath: dirname(__DIR__))
->withRouting(
Expand All @@ -29,4 +32,42 @@
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);

$exceptions->respond(function (Response $response, Throwable $exception, Request $request) {
$status = $response->getStatusCode();

// JSON callers keep their own error handling.
if ($response instanceof JsonResponse) {
return $response;
}

// An expired session is not worth an error page: send the user back
// to the form so reloading and resubmitting is the obvious next step.
if ($status === 419) {
Inertia::flash('toast', ['type' => 'error', 'message' => __('Your session expired. Please try again.')]);

return back();
}

// 403 and 404 carry no debug detail, so they always get the app's page.
// Server errors keep the debug screen wherever debug mode is on.
$showsErrorPage = in_array($status, [403, 404], true)
|| (in_array($status, [500, 503], true) && ! config('app.debug'));

if (! $showsErrorPage) {
return $response;
}

try {
return Inertia::render('error-page', ['status' => $status])
->toResponse($request)
->setStatusCode($status);
} catch (Throwable $renderException) {
// If the app cannot render (database down, missing build), fall
// back to Laravel's plain page rather than failing twice.
report($renderException);

return $response;
}
});
})->create();
12 changes: 8 additions & 4 deletions resources/js/app.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createInertiaApp } from '@inertiajs/react';
import ErrorBoundary from '@/components/error-boundary';
import { Toaster } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { initializeTheme } from '@/hooks/use-appearance';
Expand All @@ -13,6 +14,7 @@ void createInertiaApp({
layout: (name) => {
switch (true) {
case name === 'welcome':
case name === 'error-page':
return null;
case name.startsWith('auth/'):
return AuthLayout;
Expand All @@ -26,10 +28,12 @@ void createInertiaApp({
strictMode: true,
withApp(app) {
return (
<TooltipProvider delayDuration={0}>
{app}
<Toaster />
</TooltipProvider>
<ErrorBoundary>
<TooltipProvider delayDuration={0}>
{app}
<Toaster />
</TooltipProvider>
</ErrorBoundary>
);
},
progress: {
Expand Down
72 changes: 72 additions & 0 deletions resources/js/components/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { RefreshCw, TriangleAlert } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button } from '@/components/ui/button';

type Props = { children: ReactNode };
type State = { error: Error | null };

/**
* Catch render errors instead of letting them blank the page.
*
* React unmounts the whole tree when a render throws, which leaves a white
* screen and no clue what happened. Anything that gets this far is a bug,
* so the point is to say so plainly and offer a way out.
*/
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };

static getDerivedStateFromError(error: Error): State {
return { error };
}

componentDidCatch(error: Error, info: ErrorInfo) {
// Keep the stack in the console for whoever is looking at it.
console.error('Unhandled render error', error, info.componentStack);
}

render() {
const { error } = this.state;

if (!error) {
return this.props.children;
}

return (
<div
data-test="error-boundary"
className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center"
>
<div className="bg-muted flex size-12 items-center justify-center rounded-full">
<TriangleAlert className="text-muted-foreground size-6" />
</div>

<div className="space-y-1">
<h1 className="text-lg font-semibold">
Something went wrong on this page
</h1>
<p className="text-muted-foreground text-sm">
The page could not finish loading. Reloading usually
clears it; if it keeps happening, the details below are
worth reporting.
</p>
</div>

<div className="flex gap-2">
<Button onClick={() => window.location.reload()}>
<RefreshCw /> Reload the page
</Button>
<Button variant="secondary" asChild>
<a href="/">Go back home</a>
</Button>
</div>

<pre
data-test="error-boundary-message"
className="bg-muted text-muted-foreground max-w-xl overflow-x-auto rounded-md p-3 text-left text-xs"
>
{error.message}
</pre>
</div>
);
}
}
88 changes: 88 additions & 0 deletions resources/js/pages/error-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Head, Link } from '@inertiajs/react';
import {
ArrowLeft,
Ban,
FileQuestion,
ServerCrash,
Wrench,
} from 'lucide-react';
import AppLogoIcon from '@/components/app-logo-icon';
import { Button } from '@/components/ui/button';
import { home } from '@/routes';

type Status = 403 | 404 | 500 | 503;

const messages: Record<
Status,
{ icon: typeof Ban; title: string; description: string }
> = {
403: {
icon: Ban,
title: 'You don’t have access to this page',
description:
'It may belong to an organization you aren’t a member of, or your role doesn’t allow it. Ask an organization owner if you think you should have access.',
},
404: {
icon: FileQuestion,
title: 'We couldn’t find that page',
description:
'The link may be old, or the item was moved or deleted. Check the address, or head back and pick up from there.',
},
500: {
icon: ServerCrash,
title: 'Something went wrong on our side',
description:
'The error has been logged. Try again in a moment; if it keeps happening, let us know what you were doing.',
},
503: {
icon: Wrench,
title: 'We’ll be right back',
description:
'SalesReport is being updated. This usually takes a minute or two, so try again shortly.',
},
};

export default function ErrorPage({ status }: { status: Status }) {
const {
icon: Icon,
title,
description,
} = messages[status] ?? messages[500];

return (
<>
<Head title={title} />

<main className="bg-muted/30 flex min-h-svh flex-col items-center justify-center px-5 py-10">
<div className="workspace-panel w-full max-w-md p-6 text-center sm:p-8">
<div className="bg-primary text-primary-foreground mx-auto mb-6 flex size-10 items-center justify-center rounded-xl">
<AppLogoIcon className="size-6" />
</div>

<p className="text-muted-foreground mb-3 inline-flex items-center gap-2 text-xs font-medium tracking-[0.18em] uppercase">
<Icon className="size-4" /> Error {status}
</p>

<h1 className="text-2xl font-semibold tracking-tight">
{title}
</h1>
<p className="text-muted-foreground mt-3 text-sm leading-relaxed">
{description}
</p>

<div className="mt-8 flex flex-col-reverse justify-center gap-2 sm:flex-row">
<Button
variant="secondary"
onClick={() => window.history.back()}
>
<ArrowLeft /> Go back
</Button>
<Button asChild>
<Link href={home()}>Go to home page</Link>
</Button>
</div>
</div>
</main>
</>
);
}
2 changes: 1 addition & 1 deletion routes/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
});

Route::middleware(['auth', 'verified'])->group(function () {
Route::middleware(['auth'])->group(function () {
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');

Route::inertia('settings/appearance', 'settings/appearance')->name('appearance.edit');
Expand Down
6 changes: 3 additions & 3 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@
->name('logout');

Route::get('onboarding', OnboardingController::class)
->middleware(['auth', 'verified'])
->middleware(['auth'])
->name('onboarding');

Route::prefix('{current_organization}')
->middleware(['auth', 'verified', EnsureOrganizationMembership::class])
->middleware(['auth', EnsureOrganizationMembership::class])
->scopeBindings()
->group(function () {
Route::get('dashboard', DashboardController::class)->name('dashboard');
Expand All @@ -62,7 +62,7 @@
});

Route::get('invitations', [OrganizationInvitationController::class, 'index'])
->middleware(['auth', 'verified'])
->middleware(['auth'])
->name('invitations.index');

Route::middleware(['auth'])->group(function () {
Expand Down
66 changes: 66 additions & 0 deletions tests/Feature/ErrorPageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Facades\Route;
use Inertia\Testing\AssertableInertia as Assert;

test('a missing page renders the app error page', function () {
$this->get('/this-page-does-not-exist')
->assertNotFound()
->assertInertia(fn (Assert $page) => $page
->component('error-page')
->where('status', 404),
);
});

test('an organization the user does not belong to renders the forbidden page', function () {
$user = User::factory()->create();
$other = Organization::factory()->create();

$this->actingAs($user)
->get(route('dashboard', ['current_organization' => $other->slug]))
->assertForbidden()
->assertInertia(fn (Assert $page) => $page
->component('error-page')
->where('status', 403),
);
});

test('server errors render the error page when debug mode is off', function () {
config(['app.debug' => false]);

Route::get('/boom', fn () => throw new RuntimeException('Boom'))->middleware('web');

$this->get('/boom')
->assertInternalServerError()
->assertInertia(fn (Assert $page) => $page
->component('error-page')
->where('status', 500),
);
});

test('server errors keep the debug screen when debug mode is on', function () {
config(['app.debug' => true]);

Route::get('/boom', fn () => throw new RuntimeException('Boom'))->middleware('web');

$this->get('/boom')
->assertInternalServerError()
->assertDontSee('"component":"error-page"', false);
});

test('json requests still get json errors', function () {
$this->getJson('/this-page-does-not-exist')
->assertNotFound()
->assertJsonStructure(['message']);
});

test('an expired session sends the user back with a message', function () {
Route::post('/expired', fn () => abort(419))->middleware('web');

$this->from('/settings/profile')
->post('/expired')
->assertRedirect('/settings/profile')
->assertSessionHas('inertia.flash_data.toast.type', 'error');
});
Loading