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,97 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Actions\Auth\SyncPireonUser;
use App\Http\Controllers\Controller;
use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
/**
* POST /api/v1/auth/token
*
* A thin, rate-limited proxy over the identity server's password and
* refresh_token grants, so first-party clients (mobile apps, test suites) can
* obtain a token without embedding the OAuth server's URL and client secret.
*
* The panel's sign-in form uses the same grant directly, so
* `PIREON_PASSWORD_GRANT=false` closes both doors at once.
*/
class TokenController extends Controller
{
public function __construct(
protected PireonClient $client,
protected SyncPireonUser $sync,
) {}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'grant_type' => ['required', 'in:password,refresh_token'],
'username' => ['required_if:grant_type,password', 'string'],
'password' => ['required_if:grant_type,password', 'string'],
'refresh_token' => ['required_if:grant_type,refresh_token', 'string'],
'scope' => ['sometimes', 'string'],
]);
$scopes = isset($data['scope'])
? array_values(array_filter(explode(' ', $data['scope'])))
: null;
try {
$token = $data['grant_type'] === 'password'
? $this->client->requestPasswordToken($data['username'], $data['password'], $scopes)
: $this->client->refresh($data['refresh_token']);
$identity = $this->client->identity($token->accessToken);
} catch (PireonException $e) {
// An identity server that failed to answer has said nothing about
// these credentials, so it must not be reported as though it had.
if (! $e->isRejection()) {
return response()->json([
'message' => __('pireon.server_unavailable'),
], Response::HTTP_SERVICE_UNAVAILABLE);
}
// Otherwise a validation error rather than the upstream body, so
// we never echo details of the identity server back to a caller
// probing for valid accounts.
throw ValidationException::withMessages([
'username' => [__('auth.failed')],
])->status($e->status === 401 ? 401 : 422);
}
if (! $identity->isUsable()) {
return response()->json([
'message' => $identity->isApproved()
? __('pireon.inactive')
: __('pireon.pending_approval'),
], 403);
}
// Provision the local account now, so the very first API call after
// this one already has roles and a user row to authorise against.
$user = $this->sync->handle($identity);
return response()->json([
'access_token' => $token->accessToken,
'refresh_token' => $token->refreshToken,
'token_type' => $token->tokenType,
'expires_at' => $token->expiresAt?->toIso8601String(),
'scopes' => $token->scopes,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'roles' => $user->roles->pluck('name')->values(),
'permissions' => $user->getAllPermissions()->pluck('name')->values(),
],
]);
}
}