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,80 @@
<?php
declare(strict_types=1);
namespace App\Services\Pireon;
use RuntimeException;
use Throwable;
/**
* Raised when the identity server cannot be reached, rejects our credentials,
* or answers with something we cannot interpret.
*/
class PireonException extends RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
/** @var array<string, mixed> */
public readonly array $context = [],
?Throwable $previous = null,
) {
parent::__construct($message, $status ?? 0, $previous);
}
public static function misconfigured(string $key): self
{
return new self(
"The Pireon OAuth client is not configured: [{$key}] is missing. ".
'Register this app on the identity server and fill in the PIREON_* keys in .env.'
);
}
/**
* @param array<string, mixed> $body
*/
public static function tokenRequestFailed(int $status, array $body): self
{
$error = $body['error'] ?? 'unknown_error';
$description = $body['error_description'] ?? $body['message'] ?? 'no description given';
return new self(
"The identity server rejected the token request [{$error}]: {$description}",
status: $status,
context: $body,
);
}
/**
* Whether the identity server actually judged the credentials, as opposed
* to never getting far enough to look at them.
*
* The difference matters to whoever is staring at the screen: "those
* credentials are wrong" sends them to check their password, and if the
* truth is that the server answered 500 — or did not answer at all — that
* is an hour of their life spent on the wrong problem.
*/
public function isRejection(): bool
{
return in_array($this->status, [400, 401, 403], true);
}
public static function unreachable(string $url, ?Throwable $previous = null): self
{
return new self(
"The identity server at [{$url}] could not be reached.",
context: ['url' => $url],
previous: $previous,
);
}
public static function invalidState(): self
{
return new self(
'The OAuth callback state did not match the one we issued. '.
'The login attempt was discarded; please start again.',
status: 400,
);
}
}