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,15 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Roles\Pages;
use App\Filament\Resources\Roles\RoleResource;
use Filament\Resources\Pages\CreateRecord;
class CreateRole extends CreateRecord
{
use HandlesRolePermissions;
protected static string $resource = RoleResource::class;
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Roles\Pages;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Roles\RoleResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditRole extends EditRecord
{
use HandlesRolePermissions, WarnsOfConcurrentEditing;
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Roles\Pages;
use App\Support\Permissions;
use Spatie\Permission\PermissionRegistrar;
/**
* Bridges the form's per-group checkbox state and the role's single
* `permissions` relationship.
*
* The form splits permissions across one component per resource for
* readability; the database wants one flat set. This trait converts in both
* directions and keeps the two in step.
*/
trait HandlesRolePermissions
{
/**
* Permission names captured on the way to saving, held here because the
* relationship can only be synced once the role row exists.
*
* @var array<int, string>
*/
protected array $selectedPermissions = [];
/**
* Expand the role's flat permission set into the form's grouped shape.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeFill(array $data): array
{
$held = $this->getRecord()->permissions->pluck('name')->all();
foreach (Permissions::grouped() as $group => $permissions) {
$data['permission_groups'][$group] = array_values(array_intersect($permissions, $held));
}
return $data;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
return $this->extractPermissions($data);
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
return $this->extractPermissions($data);
}
protected function afterSave(): void
{
$this->syncSelectedPermissions();
}
protected function afterCreate(): void
{
$this->syncSelectedPermissions();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function extractPermissions(array $data): array
{
$groups = $data['permission_groups'] ?? [];
unset($data['permission_groups']);
// Intersecting with the catalogue means a stale or hand-crafted value
// in the payload cannot grant a permission the app does not define.
$this->selectedPermissions = array_values(array_intersect(
array_merge(...array_values(array_map(fn ($v) => (array) $v, $groups)) ?: [[]]),
Permissions::all(),
));
return $data;
}
protected function syncSelectedPermissions(): void
{
$this->getRecord()->syncPermissions($this->selectedPermissions);
// spatie caches the permission map; without this the change would not
// take effect until the cache expired.
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Roles\Pages;
use App\Filament\Resources\Roles\RoleResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListRoles extends ListRecords
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}