Initial commit: Pireon Admin — headless admin panel and public API

Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Auth;
use App\Actions\Auth\SyncPireonUser;
use App\Services\Pireon\AccessToken;
use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException;
use App\Services\Pireon\TokenStore;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Filament\Auth\Pages\Login;
use Filament\Facades\Filament;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use SensitiveParameter;
/**
* The panel's login screen: an ordinary e-mail and password form.
*
* The identity server is still the only thing that decides whether those
* credentials are correct — there are no local passwords, and `users` has no
* usable password column — but the person signing in never sees that. They
* type their credentials here and land in the panel.
*
* The cost of that convenience is stated plainly because it is real: this
* application now handles the password rather than never seeing it. It is sent
* straight to the identity server over the password grant and is never stored,
* logged, or written anywhere; but a compromise here would expose credentials
* to the identity server, which the redirect flow made impossible. The
* Authorization Code + PKCE routes are still in place
* ([`PireonAuthController`](app/Http/Controllers/Auth/PireonAuthController.php))
* for anyone who wants that trade back.
*/
class PireonLogin extends Login
{
public function getTitle(): string|Htmlable
{
return __('pireon.sign_in');
}
public function getHeading(): string|Htmlable
{
return __('pireon.sign_in');
}
public function getSubheading(): string|Htmlable|null
{
return __('pireon.sign_in_intro');
}
/**
* Filament's own flow checks the credentials against the local user table.
* There are no local passwords here, so the whole of it is replaced: the
* identity server is asked instead, and only what it says is trusted.
*/
public function authenticate(): ?LoginResponse
{
try {
// Filament's own limiter, keyed by IP: five attempts, then a wait.
// The identity server has its own; this stops us spending it.
$this->rateLimit(5);
} catch (TooManyRequestsException $exception) {
$this->getRateLimitedNotification($exception)?->send();
return null;
}
$data = $this->form->getState();
$this->assertUsable();
$token = $this->exchange((string) $data['email'], (string) $data['password']);
$client = app(PireonClient::class);
try {
$identity = $client->identity($token->accessToken);
} catch (PireonException $e) {
$this->fail($e);
}
// Said only once the credentials were right, so this cannot be used to
// find out which addresses have an account.
if (! $identity->isUsable()) {
throw ValidationException::withMessages([
'data.email' => $identity->isApproved()
? __('pireon.inactive')
: __('pireon.pending_approval'),
]);
}
$user = app(SyncPireonUser::class)->handle($identity);
// Being a valid account upstream is not the same as being allowed in
// here: roles are this application's business.
if (! $user->canAccessPanel(Filament::getCurrentOrDefaultPanel())) {
throw ValidationException::withMessages([
'data.email' => __('pireon.no_permission'),
]);
}
// Rotate the session id so a fixated pre-login session cannot be
// reused now that it carries an authenticated identity.
session()->regenerate();
Auth::login($user, (bool) ($data['remember'] ?? false));
app(TokenStore::class)->put($token);
if ($user->locale) {
session()->put(config('locales.session_key'), $user->locale);
}
return app(LoginResponse::class);
}
/**
* Trade the credentials for a token upstream.
*
* Any refusal answers with the same message. The identity server
* distinguishes an unknown address from a wrong password; repeating that
* here would turn the form into a way of finding out who has an account.
*/
protected function exchange(string $email, #[SensitiveParameter] string $password): AccessToken
{
try {
return app(PireonClient::class)->requestPasswordToken($email, $password);
} catch (PireonException $e) {
$this->fail($e);
}
}
/**
* A misconfiguration is an operator's problem, not a visitor's, so it says
* what is actually wrong instead of "those credentials are incorrect".
*/
protected function assertUsable(): void
{
if (! app(PireonClient::class)->isConfigured()) {
throw ValidationException::withMessages([
'data.email' => __('pireon.not_configured_body', ['server' => (string) config('pireon.server')]),
]);
}
if (! config('pireon.password_grant_enabled')) {
throw ValidationException::withMessages([
'data.email' => __('pireon.password_grant_disabled'),
]);
}
}
protected function fail(PireonException $e): never
{
// The address is logged, the password never is — it does not leave the
// request it arrived in.
Log::warning('Pireon sign-in failed', [
'message' => $e->getMessage(),
'status' => $e->status,
]);
// Only say "those credentials are wrong" when that is what happened.
// A server that answered 500, or did not answer at all, has said
// nothing about the password, and reporting otherwise sends whoever is
// signing in to look for a problem that is not theirs.
if (! $e->isRejection()) {
throw ValidationException::withMessages([
'data.email' => __('pireon.server_unavailable'),
]);
}
$this->throwFailureValidationException();
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use App\Support\Health\Severity;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
/**
* Everything wrong with the public site, in one place.
*
* This page exists because the site is somewhere else. A missing translation,
* two records answering at the same URL, an image whose file is gone: none of
* it is visible where the content is edited. It shows up on the frontend,
* usually late, and usually to a visitor rather than to us.
*/
class SiteHealth extends Page
{
protected string $view = 'filament.pages.site-health';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
protected static ?int $navigationSort = 88;
/**
* Not a public Livewire property: a Finding is a plain object, and
* Livewire can only carry types it knows how to serialise. It is
* recomputed per render instead, which is what opening this page is for.
*
* @var Collection<int, Finding>|null
*/
protected ?Collection $findings = null;
public static function canAccess(): bool
{
// Anyone who edits content benefits from seeing what is wrong with it.
// Individual checks hide themselves behind their own permission.
return Auth::user()?->can('panel.access') ?? false;
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.administration');
}
public static function getNavigationLabel(): string
{
return __('health.title');
}
public function getTitle(): string|Htmlable
{
return __('health.title');
}
public function getSubheading(): string|Htmlable|null
{
return __('health.subheading');
}
/**
* @return Collection<int, Finding>
*/
public function findings(): Collection
{
return $this->findings ??= app(HealthReport::class)->runFor(Auth::user());
}
/**
* @return array<int, Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('refresh')
->label(__('health.actions.refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action(function (): void {
$this->refreshFindings();
Notification::make()
->title(__('health.refreshed'))
->success()
->send();
}),
];
}
public function refreshFindings(): void
{
// The badge is cached so that drawing it does not run ten checks on
// every page of the panel; opening this page is the one moment we
// definitely want fresh answers.
app(HealthReport::class)->forget(Auth::user());
$this->findings = null;
}
/**
* @return Collection<int, Finding>
*/
public function problems(): Collection
{
return $this->findings()->filter(fn (Finding $f) => $f->severity->isProblem());
}
/**
* @return Collection<int, Finding>
*/
public function passing(): Collection
{
return $this->findings()->reject(fn (Finding $f) => $f->severity->isProblem());
}
public function hasErrors(): bool
{
return $this->problems()->contains(fn (Finding $f) => $f->severity === Severity::Error);
}
/**
* A count in the sidebar, because a diagnostics page nobody opens
* diagnoses nothing.
*/
public static function getNavigationBadge(): ?string
{
$problems = app(HealthReport::class)->problemCount(Auth::user());
return $problems > 0 ? (string) $problems : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
}

View File

@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\Setting;
use App\Support\Maintenance;
use BackedEnum;
use Carbon\CarbonImmutable;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
/**
* Puts the **public site** into maintenance, without touching the panel.
*
* The distinction is the whole feature: `artisan down` would lock out the
* collective as well, exactly when they need to be fixing whatever went wrong.
* Here the team keeps editing, repositories keep syncing, and only visitors of
* the public site see the notice.
*/
class SiteMaintenance extends Page
{
protected string $view = 'filament.pages.site-maintenance';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedWrenchScrewdriver;
protected static ?int $navigationSort = 90;
/**
* @var array<string, mixed>|null
*/
public ?array $data = [];
public static function canAccess(): bool
{
return Auth::user()?->can('settings.view') ?? false;
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.administration');
}
public static function getNavigationLabel(): string
{
return __('maintenance.title');
}
public function getTitle(): string|Htmlable
{
return __('maintenance.title');
}
public function getSubheading(): string|Htmlable|null
{
return __('maintenance.subheading');
}
/**
* A red badge in the sidebar while the site is down — this is not
* something anyone should be able to forget about.
*/
public static function getNavigationBadge(): ?string
{
return Maintenance::isEnabled() ? __('maintenance.badge') : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'danger';
}
public function mount(): void
{
$this->form->fill([
'message' => Maintenance::messages(),
'until' => Maintenance::until()?->toDateTimeString(),
]);
}
public function form(Schema $schema): Schema
{
return $schema
->statePath('data')
->components([
Section::make(__('maintenance.notice'))
->description(__('maintenance.notice_hint'))
->schema([
Tabs::make('message')
->tabs(collect(config('locales.supported'))
->map(fn (array $meta, string $locale) => Tab::make($locale)
->label(trim(($meta['flag'] ?? '').' '.($meta['native'] ?? $locale)))
->schema([
Textarea::make("message.{$locale}")
->hiddenLabel()
->rows(3)
->placeholder(__('maintenance.default_message', [], $locale))
->maxLength(500),
]))
->values()
->all())
->columnSpanFull(),
DateTimePicker::make('until')
->label(__('maintenance.until'))
->helperText(__('maintenance.until_hint'))
->seconds(false)
->minDate(now()),
]),
]);
}
/**
* @return array<int, Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('enable')
->label(__('maintenance.enable'))
->icon('heroicon-o-exclamation-triangle')
->color('danger')
->requiresConfirmation()
->modalDescription(__('maintenance.enable_confirm'))
->visible(fn () => ! Maintenance::isEnabled() && $this->canToggle())
->action(function (): void {
$data = $this->form->getState();
Maintenance::enable(
message: array_filter($data['message'] ?? [], fn ($v) => filled($v)),
until: filled($data['until'] ?? null) ? CarbonImmutable::parse($data['until']) : null,
);
Notification::make()
->warning()
->title(__('maintenance.enabled_notice'))
->send();
}),
Action::make('disable')
->label(__('maintenance.disable'))
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->visible(fn () => Maintenance::isEnabled() && $this->canToggle())
->action(function (): void {
Maintenance::disable();
Notification::make()
->success()
->title(__('maintenance.disabled_notice'))
->send();
}),
Action::make('save')
->label(__('maintenance.save_message'))
->color('gray')
->visible(fn () => $this->canToggle())
->action(function (): void {
$data = $this->form->getState();
$messages = array_filter($data['message'] ?? [], fn ($v) => filled($v));
// Saving the wording must not itself take the site down.
if (Maintenance::isEnabled()) {
Maintenance::enable(
message: $messages,
until: filled($data['until'] ?? null) ? CarbonImmutable::parse($data['until']) : null,
);
} else {
Setting::set(Maintenance::KEY, [
...Maintenance::state(),
'enabled' => false,
'message' => $messages,
]);
}
Notification::make()
->success()
->title(__('maintenance.message_saved'))
->send();
}),
];
}
public function isEnabled(): bool
{
return Maintenance::isEnabled();
}
public function startedAt(): ?string
{
return Maintenance::startedAt()?->diffForHumans();
}
public function endsAt(): ?string
{
return Maintenance::until()?->isoFormat('LLL');
}
public function canToggle(): bool
{
return Auth::user()?->can('settings.maintenance') ?? false;
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\Application;
use App\Models\Category;
use App\Models\Client;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\Testimonial;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page as FilamentPage;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
/**
* Everything deleted, in one place.
*
* Soft deletes are everywhere, but recovering something meant remembering
* which resource it had been in and then turning on a filter — which is a lot
* to ask of somebody who has just deleted the wrong thing and is looking for
* the undo button.
*/
class Trash extends FilamentPage
{
protected string $view = 'filament.pages.trash';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTrash;
protected static ?int $navigationSort = 87;
/**
* Every model that can be recovered, and the permission that governs it.
*
* @var array<class-string<Model>, string>
*/
public const RECOVERABLE = [
Page::class => 'pages',
Project::class => 'projects',
Service::class => 'services',
Member::class => 'members',
Client::class => 'clients',
Testimonial::class => 'testimonials',
Category::class => 'categories',
// The inbox soft-deletes too, and it is gated by its own permission
// below — a viewer never sees a stranger's message here either.
Application::class => 'applications',
];
public static function canAccess(): bool
{
return Auth::user()?->can('panel.access') ?? false;
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.administration');
}
public static function getNavigationLabel(): string
{
return __('panel.trash.title');
}
public function getTitle(): string|Htmlable
{
return __('panel.trash.title');
}
public function getSubheading(): string|Htmlable|null
{
return __('panel.trash.subheading');
}
/**
* What is in the bin, grouped by what it is — and only the kinds this user
* is allowed to see in the first place.
*
* @return Collection<string, array{label: string, records: Collection<int, Model>}>
*/
public function groups(): Collection
{
$user = Auth::user();
return collect(self::RECOVERABLE)
->filter(fn (string $resource) => $user?->can("{$resource}.view_any") ?? false)
->map(fn (string $resource, string $model) => [
'model' => $model,
'label' => __("panel.{$resource}.plural"),
'records' => $model::onlyTrashed()->latest('deleted_at')->limit(25)->get(),
])
->filter(fn (array $group) => $group['records']->isNotEmpty());
}
public function total(): int
{
return $this->groups()->sum(fn (array $group) => $group['records']->count());
}
/**
* Put one thing back.
*/
public function restoreRecord(string $model, int|string $id): void
{
$record = $this->find($model, $id);
if (! $record || ! Auth::user()?->can('restore', $record)) {
return;
}
$record->restore();
Notification::make()
->title(__('panel.trash.restored'))
->success()
->send();
}
/**
* Erase one thing for good.
*/
public function eraseRecord(string $model, int|string $id): void
{
$record = $this->find($model, $id);
if (! $record || ! Auth::user()?->can('forceDelete', $record)) {
return;
}
$record->forceDelete();
Notification::make()
->title(__('panel.trash.erased'))
->warning()
->send();
}
/**
* How a record is described here, whatever kind it is.
*/
public function labelFor(Model $record): string
{
foreach (['name', 'title', 'author_name', 'full_name'] as $attribute) {
$value = $record->getAttribute($attribute);
if (blank($value)) {
continue;
}
return is_array($value)
? (string) ($value[config('locales.default')] ?? reset($value))
: (string) $value;
}
return '#'.$record->getKey();
}
protected function find(string $model, int|string $id): ?Model
{
// Only the models this page declares: an id and a class name arriving
// from the browser must not be able to reach anything else.
if (! array_key_exists($model, self::RECOVERABLE)) {
return null;
}
return $model::onlyTrashed()->find($id);
}
public static function getNavigationBadge(): ?string
{
return null;
}
}