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:
15
app/Filament/Resources/Roles/Pages/CreateRole.php
Normal file
15
app/Filament/Resources/Roles/Pages/CreateRole.php
Normal 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;
|
||||
}
|
||||
24
app/Filament/Resources/Roles/Pages/EditRole.php
Normal file
24
app/Filament/Resources/Roles/Pages/EditRole.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
100
app/Filament/Resources/Roles/Pages/HandlesRolePermissions.php
Normal file
100
app/Filament/Resources/Roles/Pages/HandlesRolePermissions.php
Normal 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();
|
||||
}
|
||||
}
|
||||
21
app/Filament/Resources/Roles/Pages/ListRoles.php
Normal file
21
app/Filament/Resources/Roles/Pages/ListRoles.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
69
app/Filament/Resources/Roles/RoleResource.php
Normal file
69
app/Filament/Resources/Roles/RoleResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
85
app/Filament/Resources/Roles/Schemas/RoleForm.php
Normal file
85
app/Filament/Resources/Roles/Schemas/RoleForm.php
Normal 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;
|
||||
}
|
||||
}
|
||||
53
app/Filament/Resources/Roles/Tables/RolesTable.php
Normal file
53
app/Filament/Resources/Roles/Tables/RolesTable.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user