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
70 changes: 70 additions & 0 deletions resources/views/errors/page.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
@php
$pages = [
401 => ['title' => 'Authentication required', 'text' => 'Sign in to continue to the admin area.', 'icon' => 'lock'],
403 => ['title' => 'Access denied', 'text' => 'You do not have permission to view this page.', 'icon' => 'shield'],
404 => ['title' => 'Page not found', 'text' => 'This admin page may have moved, or the address may be incorrect.', 'icon' => 'search'],
419 => ['title' => 'Session expired', 'text' => 'Your session has expired. Refresh the page and try again.', 'icon' => 'clock'],
429 => ['title' => 'Too many requests', 'text' => 'You have made too many requests. Please try again shortly.', 'icon' => 'clock'],
500 => ['title' => 'Something went wrong', 'text' => 'An unexpected error occurred. Please try again later.', 'icon' => 'alert'],
503 => ['title' => 'Temporarily unavailable', 'text' => 'The admin area is temporarily unavailable. Please try again soon.', 'icon' => 'alert'],
];
$page = $pages[$status] ?? $pages[500];
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{ $status }} · {{ $page['title'] }} · {{ config('app.name', 'Lazy Admin') }}</title>
<style>
/* Self-contained DaisyUI-inspired palette for error handling without assets. */
:root { color-scheme: light; font-family: ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
--base-100:#fff;--base-200:#f5f6f9;--base-300:#e3e6ed;--content:#222737;--muted:#687385;--primary:#4f46e5;--primary-content:#fff; }
@media(prefers-color-scheme:dark){:root{color-scheme:dark;--base-100:#1a2030;--base-200:#121827;--base-300:#30394b;--content:#eef0f6;--muted:#a6adbd;--primary:#a5b4fc;--primary-content:#161b30}}
*{box-sizing:border-box}body{min-height:100vh;min-height:100dvh;margin:0;display:flex;flex-direction:column;background:var(--base-200);color:var(--content)}
.topbar{height:4rem;display:flex;align-items:center;gap:.7rem;padding:0 clamp(1rem,3vw,2rem);border-bottom:1px solid var(--base-300);font-size:.875rem;font-weight:600}
.brand{display:grid;place-items:center;width:2.3rem;height:2.3rem;border-radius:.65rem;background:var(--primary);color:var(--primary-content)}
.brand svg{width:1.4rem;height:1.4rem}.subtle{color:var(--muted);font-weight:400}
main{flex:1;display:grid;place-items:center;padding:2rem 1rem}
.card{width:min(100%,37rem);padding:clamp(1.5rem,6vw,3.25rem);background:var(--base-100);border:1px solid var(--base-300);border-radius:1rem;text-align:center;box-shadow:0 8px 30px #00000008}
.symbol{width:4.5rem;height:4.5rem;margin:0 auto 1.5rem;border-radius:1rem;display:grid;place-items:center;background:var(--base-200);border:1px solid var(--base-300);color:var(--primary)}
.symbol svg{width:2rem;height:2rem;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
.status{font-size:clamp(4.25rem,14vw,6rem);font-weight:800;letter-spacing:-.075em;line-height:1;color:var(--primary)}
h1{font-size:clamp(1.4rem,4vw,1.875rem);letter-spacing:-.04em;margin:1.25rem 0 .75rem}
p{color:var(--muted);line-height:1.65;max-width:26rem;margin:0 auto 1.75rem;font-size:.925rem}
.actions{display:flex;flex-wrap:wrap;justify-content:center;gap:.75rem}
.btn{appearance:none;display:inline-flex;align-items:center;justify-content:center;min-height:2.75rem;padding:.65rem 1.1rem;border:1px solid var(--base-300);border-radius:.5rem;background:var(--base-100);color:var(--content);text-decoration:none;font:inherit;font-size:.875rem;font-weight:600;cursor:pointer}
.btn:hover{background:var(--base-200)}.btn-primary{background:var(--primary);border-color:var(--primary);color:var(--primary-content)}.btn-primary:hover{filter:brightness(.92);background:var(--primary)}
.btn:focus-visible{outline:2px solid var(--primary);outline-offset:3px}
footer{text-align:center;color:var(--muted);padding:1rem;font-size:.75rem}
</style>
</head>
<body>
<header class="topbar"><div class="brand" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 21 7v10l-9 5-9-5V7l9-5Z"/><path d="m8 15 4-8 4 8M9.5 12h5"/></svg></div><span>{{ config('app.name', 'Lazy Admin') }}</span><span class="subtle">/ Admin</span></header>
<main><section class="card" aria-labelledby="error-title">
<div class="symbol" aria-hidden="true">
@if($page['icon'] === 'search')
<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5M8 9h.01"/></svg>
@elseif($page['icon'] === 'lock')
<svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
@elseif($page['icon'] === 'shield')
<svg viewBox="0 0 24 24"><path d="M12 2 20 6v6c0 5-3.5 8-8 10-4.5-2-8-5-8-10V6l8-4Z"/><path d="m9 12 2 2 4-4"/></svg>
@elseif($page['icon'] === 'clock')
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
@else
<svg viewBox="0 0 24 24"><path d="M12 3 2 21h20L12 3Z"/><path d="M12 9v5M12 18h.01"/></svg>
@endif
</div>
<div class="status">{{ $status }}</div>
<h1 id="error-title">{{ $page['title'] }}</h1>
<p>{{ $page['text'] }}</p>
<div class="actions">
<a class="btn btn-primary" href="{{ $homeUrl }}">Back to dashboard</a>
<button class="btn" type="button" onclick="window.location.reload()">Try again</button>
</div>
</section>
</main>
<footer>Lazy Admin · {{ $status }}</footer>
</body>
</html>
68 changes: 68 additions & 0 deletions src/Exceptions/AdminErrorPages.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace Step2dev\LazyAdmin\Exceptions;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Throwable;

final class AdminErrorPages
{
public function __invoke(Throwable $exception, Request $request): ?Response
{
if ($request->expectsJson() || $request->is('livewire/*') || ! $this->isAdminRequest($request)) {
return null;
}

if (! $exception instanceof HttpExceptionInterface) {
return null;
}

$status = $exception->getStatusCode();

if (! in_array($status, [401, 403, 404, 419, 429, 500, 503], true)) {
return null;
}

return response()->view('lazy::errors.page', [
'status' => $status,
'homeUrl' => url('/'.trim((string) config('lazy.admin.route.prefix', 'admin'), '/')),
], $status, $exception->getHeaders());
}

private function isAdminRequest(Request $request): bool
{
$domain = config('lazy.admin.route.domain');

if (is_string($domain) && $domain !== '' && $request->getHost() !== $domain) {
return false;
}

$routeName = $request->route()?->getName();
$namePrefix = trim((string) config('lazy.admin.route.name', 'admin.'), '.');

if (is_string($routeName) && $namePrefix !== '' &&
($routeName === $namePrefix || str_starts_with($routeName, $namePrefix.'.'))) {
return true;
}

$prefix = trim((string) config('lazy.admin.route.prefix', 'admin'), '/');

if ($prefix === '') {
return false;
}

$segments = explode('/', trim($request->decodedPath(), '/'));
$prefixSegments = explode('/', $prefix);

if (array_slice($segments, 0, count($prefixSegments)) === $prefixSegments) {
return true;
}

// Permit a locale before the admin prefix on a missing route.
return count($segments) > count($prefixSegments)
&& preg_match('/^[a-z]{2}(?:-[A-Za-z]{2})?$/', $segments[0]) === 1
&& array_slice($segments, 1, count($prefixSegments)) === $prefixSegments;
}
}
8 changes: 8 additions & 0 deletions src/LazyAdminServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Step2dev\LazyAdmin;

use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Auth;
Expand Down Expand Up @@ -38,6 +39,7 @@
use Step2dev\LazyAdmin\Controllers\UserController;
use Step2dev\LazyAdmin\Dashboard\DashboardRegistry;
use Step2dev\LazyAdmin\Database\Seeders\DatabaseSeeder;
use Step2dev\LazyAdmin\Exceptions\AdminErrorPages;
use Step2dev\LazyAdmin\Facades\Route as RouteFacade;
use Step2dev\LazyAdmin\Http\Livewire\Activity\Page as ActivityPage;
use Step2dev\LazyAdmin\Http\Livewire\Dashboard\Page as DashboardPage;
Expand Down Expand Up @@ -203,6 +205,12 @@ public function bootingPackage(): void

public function packageBooted(): void
{
$handler = $this->app->make(ExceptionHandler::class);

if (method_exists($handler, 'renderable')) {
$handler->renderable(new AdminErrorPages);
}

View::composer('lazy::header', static function ($view): void {
$guard = (string) config('lazy.auth.guard', 'web');
$user = Auth::guard($guard)->user();
Expand Down
40 changes: 40 additions & 0 deletions tests/Feature/AdminErrorPagesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

use Illuminate\Http\Request;
use Step2dev\LazyAdmin\Exceptions\AdminErrorPages;
use Symfony\Component\HttpKernel\Exception\HttpException;

it('renders only admin HTML errors', function (): void {
config()->set('lazy.admin.route.prefix', 'admin');

$render = new AdminErrorPages;

$admin = $render(new HttpException(404), Request::create('/admin/missing'));
expect($admin)->not->toBeNull()
->and($admin->getStatusCode())->toBe(404)
->and($admin->getContent())->toContain('Page not found');

expect($render(new HttpException(404), Request::create('/public/missing')))->toBeNull();
expect($render(new HttpException(404), Request::create('/administrator/missing')))->toBeNull();
expect($render(new HttpException(404), Request::create('/admin/missing', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json'])))->toBeNull();
expect($render(new HttpException(404), Request::create('/livewire/update')))->toBeNull();
});

it('supports localized admin paths and custom prefixes', function (): void {
config()->set('lazy.admin.route.prefix', 'control');

$render = new AdminErrorPages;

expect($render(new HttpException(403), Request::create('/uk/control/private'))?->getStatusCode())->toBe(403)
->and($render(new HttpException(503), Request::create('/control/offline'))?->getStatusCode())->toBe(503)
->and($render(new HttpException(404), Request::create('/uk/admin/missing')))->toBeNull();
});

it('honors a configured admin domain', function (): void {
config()->set('lazy.admin.route.domain', 'admin.example.test');

$render = new AdminErrorPages;

expect($render(new HttpException(404), Request::create('https://example.test/admin/missing')))->toBeNull()
->and($render(new HttpException(404), Request::create('https://admin.example.test/admin/missing'))?->getStatusCode())->toBe(404);
});
Loading