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

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Roles;
use App\Filament\Resources\Roles\Pages\CreateRole;
use App\Filament\Resources\Roles\Pages\EditRole;
use App\Filament\Resources\Roles\Pages\ListRoles;
use App\Filament\Resources\Roles\Schemas\RoleForm;
use App\Filament\Resources\Roles\Tables\RolesTable;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Spatie\Permission\Models\Role;
use UnitEnum;
/**
* The authorisation model, editable in the panel.
*
* This is where "who may do what in this app" is decided — the identity server
* has no say in it.
*/
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
protected static ?int $navigationSort = 40;
protected static ?string $recordTitleAttribute = 'name';
public static function getModelLabel(): string
{
return __('panel.roles.label');
}
public static function getPluralModelLabel(): string
{
return __('panel.roles.plural');
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.administration');
}
public static function form(Schema $schema): Schema
{
return RoleForm::configure($schema);
}
public static function table(Table $table): Table
{
return RolesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListRoles::route('/'),
'create' => CreateRole::route('/create'),
'edit' => EditRole::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Roles\Schemas;
use App\Support\Permissions;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Validation\Rule;
class RoleForm
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
Section::make()->schema([
TextInput::make('name')
->label(__('panel.roles.fields.name'))
->required()
->maxLength(120)
->rules(fn ($record) => [
Rule::unique('roles', 'name')->ignore($record?->id),
])
// Renaming a structural role would break the code that
// looks it up by name.
->disabled(fn ($record) => $record && in_array($record->name, Permissions::protectedRoles(), true))
->helperText(fn ($record) => $record && in_array($record->name, Permissions::protectedRoles(), true)
? __('panel.roles.protected')
: null),
]),
// One collapsible section per resource, generated from
// App\Support\Permissions — adding a permission there makes it
// appear here with no further work.
//
// Each group writes to its own state path rather than binding the
// `permissions` relationship directly: several components sharing
// one relationship would overwrite each other on save. The page
// classes flatten these back into a single syncPermissions() call
// (see HandlesRolePermissions).
...collect(Permissions::grouped())
->map(fn (array $permissions, string $group) => Section::make(
__("panel.permissions.groups.{$group}")
)
->collapsible()
->collapsed($group !== 'general')
->schema([
CheckboxList::make("permission_groups.{$group}")
->hiddenLabel()
->options(collect($permissions)
->mapWithKeys(fn (string $name) => [$name => self::abilityLabel($name)])
->all())
->bulkToggleable()
->columns(3),
]))
->values()
->all(),
]);
}
/**
* Human label for a permission, e.g. "pages.force_delete" becomes
* "Eliminar definitivamente".
*/
public static function abilityLabel(string $permission): string
{
if ($permission === Permissions::PANEL_ACCESS) {
return __('panel.permissions.abilities.panel_access');
}
$ability = str_contains($permission, '.')
? substr($permission, strpos($permission, '.') + 1)
: $permission;
$key = "panel.permissions.abilities.{$ability}";
$label = __($key);
// Fall back to the raw name if the catalogue grows a permission that
// has no translation yet, rather than printing the missing key.
return $label === $key ? $permission : $label;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Roles\Tables;
use App\Support\Permissions;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Spatie\Permission\Models\Role;
class RolesTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('name')
->columns([
TextColumn::make('name')
->label(__('panel.roles.fields.name'))
->weight('medium')
->badge()
->color(fn (string $state) => $state === 'super_admin' ? 'danger' : 'primary')
->description(fn (Role $record) => in_array($record->name, Permissions::protectedRoles(), true)
? __('panel.roles.protected')
: null)
->searchable()
->sortable(),
TextColumn::make('permissions_count')
->label(__('panel.roles.fields.permissions'))
->counts('permissions')
// super_admin holds no permission rows: it is granted
// everything by the Gate::before hook instead.
->formatStateUsing(fn ($state, Role $record) => $record->name === 'super_admin'
? '∞'
: $state)
->badge(),
TextColumn::make('users_count')
->label(__('panel.roles.fields.users_count'))
->counts('users')
->badge()
->color('gray'),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
]);
}
}