diff --git a/.env.example b/.env.example index a0cc62c..8139c0e 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/bootstrap/app.php b/bootstrap/app.php index da912a0..1f7b646 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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( @@ -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(); diff --git a/resources/js/app.tsx b/resources/js/app.tsx index e9f7685..ac1d74c 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -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'; @@ -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; @@ -26,10 +28,12 @@ void createInertiaApp({ strictMode: true, withApp(app) { return ( - - {app} - - + + + {app} + + + ); }, progress: { diff --git a/resources/js/components/error-boundary.tsx b/resources/js/components/error-boundary.tsx new file mode 100644 index 0000000..4338bbb --- /dev/null +++ b/resources/js/components/error-boundary.tsx @@ -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 { + 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 ( +
+
+ +
+ +
+

+ Something went wrong on this page +

+

+ The page could not finish loading. Reloading usually + clears it; if it keeps happening, the details below are + worth reporting. +

+
+ +
+ + +
+ +
+                    {error.message}
+                
+
+ ); + } +} diff --git a/resources/js/pages/error-page.tsx b/resources/js/pages/error-page.tsx new file mode 100644 index 0000000..a507696 --- /dev/null +++ b/resources/js/pages/error-page.tsx @@ -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 ( + <> + + +
+
+
+ +
+ +

+ Error {status} +

+ +

+ {title} +

+

+ {description} +

+ +
+ + +
+
+
+ + ); +} diff --git a/routes/settings.php b/routes/settings.php index ca4c27a..f381423 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -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'); diff --git a/routes/web.php b/routes/web.php index 064a348..34168fd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); @@ -62,7 +62,7 @@ }); Route::get('invitations', [OrganizationInvitationController::class, 'index']) - ->middleware(['auth', 'verified']) + ->middleware(['auth']) ->name('invitations.index'); Route::middleware(['auth'])->group(function () { diff --git a/tests/Feature/ErrorPageTest.php b/tests/Feature/ErrorPageTest.php new file mode 100644 index 0000000..b407231 --- /dev/null +++ b/tests/Feature/ErrorPageTest.php @@ -0,0 +1,66 @@ +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'); +});