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,74 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\CategoryRequest;
use App\Http\Resources\CategoryResource;
use App\Models\Category;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class CategoryController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Category::class);
$locale = app()->getLocale();
$categories = Category::query()
->withCount('pages')
->when($request->has('is_visible'), fn ($q) => $q->where('is_visible', $request->boolean('is_visible')))
->when($request->filled('search'), fn ($q) => $q->where("name->{$locale}", 'like', '%'.$request->string('search').'%'))
->orderBy('position')
->orderBy("name->{$locale}")
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return CategoryResource::collection($categories);
}
public function store(CategoryRequest $request): JsonResponse
{
$this->authorize('create', Category::class);
$category = new Category;
$category->fill(TranslatablePayload::merge($category, $request->validated(), $category->translatable));
$category->save();
return CategoryResource::make($category)->response()->setStatusCode(201);
}
public function show(Category $category): CategoryResource
{
$this->authorize('view', $category);
return CategoryResource::make($category->loadCount('pages'));
}
public function update(CategoryRequest $request, Category $category): CategoryResource
{
$this->authorize('update', $category);
$category->fill(TranslatablePayload::merge($category, $request->validated(), $category->translatable));
$category->save();
return CategoryResource::make($category->loadCount('pages'));
}
public function destroy(Category $category): JsonResponse
{
// CategoryPolicy refuses while the category still holds pages, so this
// answers 403 rather than orphaning content.
$this->authorize('delete', $category);
$category->delete();
return response()->json(status: 204);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
/**
* GET /api/v1/me — who the bearer token belongs to, and what they may do here.
*
* The identity part mirrors the OAuth server's /api/user. The `permissions`
* array is this application's own answer, and is what a client should drive its
* UI from: the identity server does not know about local roles.
*/
class MeController extends Controller
{
public function __invoke(Request $request): UserResource
{
return UserResource::make(
$request->user()->load('roles')
);
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\PageStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\PageRequest;
use App\Http\Resources\PageResource;
use App\Models\Page;
use App\Support\TranslatablePayload;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class PageController extends Controller
{
/**
* GET /api/v1/pages
*
* Filters: status, category_id, is_featured, search, translated_in, trashed
* Sorting: sort=-published_at (prefix with "-" for descending)
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Page::class);
$locale = app()->getLocale();
$pages = Page::query()
->with(['category', 'author'])
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id')))
->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured')))
// Search inside the active locale's translation rather than across
// the whole JSON blob, so a Spanish query cannot match English text.
->when($request->filled('search'), fn ($q) => $q->where(
fn ($sub) => $sub
->where("title->{$locale}", 'like', '%'.$request->string('search').'%')
->orWhere("excerpt->{$locale}", 'like', '%'.$request->string('search').'%')
))
->when($request->filled('translated_in'), fn ($q) => $q->translatedIn((string) $request->string('translated_in')))
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
// Callers without the publish permission only ever see live content.
->unless($request->user()->can('pages.publish'), fn ($q) => $q->published())
->tap(fn ($q) => $this->applySort($q, $request))
->paginate(perPage: min($request->integer('per_page', 15), 100))
->withQueryString();
return PageResource::collection($pages);
}
public function store(PageRequest $request): JsonResponse
{
$this->authorize('create', Page::class);
$page = new Page;
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
$page->author_id ??= $request->user()->id;
// A page created as published with no explicit date goes live now.
if ($page->status === PageStatus::Published && $page->published_at === null) {
$page->published_at = now();
}
$page->save();
return PageResource::make($page->load(['category', 'author']))
->response()
->setStatusCode(201);
}
public function show(Request $request, Page $page): PageResource
{
$this->authorize('view', $page);
return PageResource::make($page->load(['category', 'author']));
}
public function update(PageRequest $request, Page $page): PageResource
{
$this->authorize('update', $page);
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
if ($page->status === PageStatus::Published && $page->published_at === null) {
$page->published_at = now();
}
$page->save();
return PageResource::make($page->load(['category', 'author']));
}
public function destroy(Page $page): JsonResponse
{
$this->authorize('delete', $page);
$page->delete();
return response()->json(status: 204);
}
public function restore(Page $page): PageResource
{
$this->authorize('restore', $page);
$page->restore();
return PageResource::make($page->load(['category', 'author']));
}
/**
* Whitelist of sortable columns. Anything else is ignored rather than
* passed to the query builder.
*
* @param Builder<Page> $query
*/
protected function applySort(Builder $query, Request $request): void
{
$sort = (string) $request->string('sort', '-created_at');
$direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
$column = ltrim($sort, '-');
$sortable = ['created_at', 'updated_at', 'published_at', 'position', 'status'];
// Sorting by a translatable column targets the active locale's key.
if ($column === 'title') {
$query->orderBy('title->'.app()->getLocale(), $direction);
return;
}
$query->orderBy(in_array($column, $sortable, true) ? $column : 'created_at', $direction);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\RoleResource;
use App\Support\Permissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Spatie\Permission\Models\Role;
/**
* Read-only view of the local authorisation model, so clients can render
* permission-aware UI without hard-coding role names.
*/
class RoleController extends Controller
{
public function index(): AnonymousResourceCollection
{
$this->authorize('viewAny', Role::class);
$roles = Role::query()
->with('permissions')
->withCount('users')
->orderBy('name')
->get();
return RoleResource::collection($roles);
}
public function show(Role $role): RoleResource
{
$this->authorize('view', $role);
return RoleResource::make($role->load('permissions')->loadCount('users'));
}
/**
* The full permission catalogue, grouped by resource — useful for building
* a role editor outside the panel.
*/
public function catalogue(): JsonResponse
{
$this->authorize('viewAny', Role::class);
return response()->json(['data' => Permissions::grouped()]);
}
}

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(),
],
]);
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;
use Spatie\Permission\Models\Role;
/**
* Local user administration.
*
* Only fields this application owns are writable: the local role assignment,
* the active flag, and the UI locale. Names and emails belong to the identity
* server and are refreshed from it on every login, so editing them here would
* be undone — change those at oauth.pireon.dev instead.
*/
class UserController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', User::class);
$users = User::query()
->with('roles')
->when($request->filled('search'), fn ($q) => $q->where(
fn ($sub) => $sub
->where('name', 'like', '%'.$request->string('search').'%')
->orWhere('email', 'like', '%'.$request->string('search').'%')
))
->when($request->has('is_active'), fn ($q) => $q->where('is_active', $request->boolean('is_active')))
->when($request->filled('role'), fn ($q) => $q->whereHas(
'roles',
fn ($sub) => $sub->where('name', $request->string('role'))
))
->orderBy('name')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return UserResource::collection($users);
}
public function show(User $user): UserResource
{
$this->authorize('view', $user);
return UserResource::make($user->load('roles'));
}
public function update(Request $request, User $user): UserResource
{
$this->authorize('update', $user);
$data = $request->validate([
'is_active' => ['sometimes', 'boolean'],
'locale' => ['sometimes', 'nullable', Rule::in(array_keys(config('locales.supported')))],
'roles' => ['sometimes', 'array'],
'roles.*' => [Rule::exists('roles', 'name')],
]);
if (array_key_exists('roles', $data)) {
$this->authorize('update', Role::class);
// Only a super admin may hand out super_admin, and nobody may
// strip their own privileges out from under themselves.
if (in_array('super_admin', $data['roles'], true) && ! $request->user()->hasRole('super_admin')) {
abort(403, __('pireon.cannot_grant_super_admin'));
}
$user->syncRoles($data['roles']);
unset($data['roles']);
}
$user->fill($data)->save();
return UserResource::make($user->fresh()->load('roles'));
}
}