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,25 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\User;
class ApplicationPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'applications';
}
/**
* Submissions arrive from the public site; nobody types one into the
* panel. Saying so here means the answer is the same in Filament and in
* the API, rather than only in the resource.
*/
public function create(User $user): bool
{
return false;
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Policies;
class CallPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'calls';
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Category;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class CategoryPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'categories';
}
/**
* A category still holding pages cannot be deleted; the pages would be
* silently orphaned. Empty it first.
*
* @param Category|Model $model
*/
public function delete(User $user, Model $model): bool
{
return parent::delete($user, $model) && ! $model->pages()->exists();
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Policies;
class ClientPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'clients';
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\User;
class MediaPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'media';
}
/**
* Files arrive by being uploaded on a record, not by being created here.
*/
public function create(User $user): bool
{
return false;
}
/**
* A file something still points at cannot be deleted from here: doing so
* would leave a broken image on the public site, which is precisely what
* the health check exists to complain about.
*/
public function delete(User $user, $model): bool
{
return parent::delete($user, $model) && ! $model->isReferenced();
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Member;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class MemberPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'members';
}
/**
* People may always edit their own member profile, even without the
* general permission — it is their public face, and requiring an admin for
* a bio change is friction a volunteer group does not need.
*
* @param Member|Model $model
*/
public function update(User $user, Model $model): bool
{
return $model->user_id === $user->id || parent::update($user, $model);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\MissingUrl;
use App\Models\User;
/**
* Gated by the redirect permissions rather than a set of its own.
*
* Reading this list and writing redirects are the same job — the list exists
* only to decide which addresses deserve a rule — and a permission nobody
* remembers to grant is a screen nobody can open. Whoever may fix a broken
* link may see which links are broken.
*/
class MissingUrlPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'redirects';
}
/**
* Nobody files one of these by hand: they arrive from the frontend.
*/
public function create(User $user): bool
{
return false;
}
/**
* Turning a report into a redirect is creating a redirect, so it asks for
* the permission that actually describes it.
*/
public function redirect(User $user, MissingUrl $missingUrl): bool
{
return $user->can('redirects.create');
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Enums\PageStatus;
use App\Models\Page;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class PagePolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'pages';
}
/**
* Authors may always edit their own drafts, even without the general
* update permission. Once a page is published, changing it takes the
* `pages.update` permission like anything else.
*
* @param Page|Model $model
*/
public function update(User $user, Model $model): bool
{
if (parent::update($user, $model)) {
return true;
}
return $model->author_id === $user->id && $model->status !== PageStatus::Published;
}
/**
* Moving a page into or out of the published state is a separate
* responsibility from editing its content.
*/
public function publish(User $user, ?Page $page = null): bool
{
return $user->can('pages.publish');
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Project;
use App\Models\User;
class ProjectPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'projects';
}
/**
* Putting a project on the public site is a separate responsibility from
* writing it, exactly as with pages.
*/
public function publish(User $user, ?Project $project = null): bool
{
return $user->can('projects.publish');
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Policies;
class RedirectPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'redirects';
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Repository;
use App\Models\User;
class RepositoryPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'repositories';
}
/**
* Triggering a sync spends the git provider's rate limit, which is shared
* by the whole application, so it is gated separately from editing.
*/
public function sync(User $user, ?Repository $repository = null): bool
{
return $user->can('repositories.sync');
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Database\Eloquent\Model;
/**
* Maps the standard resource abilities onto permission names of the form
* "<resource>.<ability>", so policies only have to declare their resource key
* and whatever extra rules they genuinely need.
*
* Both the Filament panel and the REST API authorise through these policies,
* which is what keeps the two surfaces from drifting apart.
*/
abstract class ResourcePolicy
{
use HandlesAuthorization;
/**
* Permission prefix, e.g. "pages".
*/
abstract protected function resource(): string;
public function viewAny(User $user): bool
{
return $user->can($this->ability('view_any'));
}
public function view(User $user, Model $model): bool
{
return $user->can($this->ability('view'));
}
public function create(User $user): bool
{
return $user->can($this->ability('create'));
}
public function update(User $user, Model $model): bool
{
return $user->can($this->ability('update'));
}
public function delete(User $user, Model $model): bool
{
return $user->can($this->ability('delete'));
}
public function deleteAny(User $user): bool
{
return $user->can($this->ability('delete'));
}
public function restore(User $user, Model $model): bool
{
return $user->can($this->ability('restore'));
}
public function forceDelete(User $user, Model $model): bool
{
return $user->can($this->ability('force_delete'));
}
protected function ability(string $ability): string
{
return "{$this->resource()}.{$ability}";
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\User;
use App\Support\Permissions;
use Illuminate\Database\Eloquent\Model;
class RolePolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'roles';
}
/**
* Only a super admin may touch the super_admin role itself — otherwise an
* administrator could edit their way up to it.
*/
public function update(User $user, Model $model): bool
{
if ($model->name === 'super_admin' && ! $user->hasRole('super_admin')) {
return false;
}
return parent::update($user, $model);
}
/**
* The bootstrapping roles (super_admin, and the role new logins receive)
* are structural: deleting one would leave the app unable to onboard or
* administer anyone.
*/
public function delete(User $user, Model $model): bool
{
if (in_array($model->name, Permissions::protectedRoles(), true)) {
return false;
}
return parent::delete($user, $model);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Policies;
class ServicePolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'services';
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Policies;
class TalkPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'talks';
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Technology;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class TechnologyPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'technologies';
}
/**
* A technology still referenced by a project, member or call cannot be
* deleted; doing so would silently strip tags across the site.
*
* @param Technology|Model $model
*/
public function delete(User $user, Model $model): bool
{
if (! parent::delete($user, $model)) {
return false;
}
return ! $model->projects()->exists()
&& ! $model->members()->exists()
&& ! $model->calls()->exists();
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Policies;
class TestimonialPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'testimonials';
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class UserPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'users';
}
/**
* Everyone may read their own record, regardless of permissions — the
* panel's profile screen depends on it.
*
* @param User|Model $model
*/
public function view(User $user, Model $model): bool
{
return $user->id === $model->id || parent::view($user, $model);
}
/**
* Nobody may edit an account more privileged than their own, and only a
* super admin may edit another super admin. Without this, an `admin` could
* grant themselves `super_admin` through the user editor.
*
* @param User|Model $model
*/
public function update(User $user, Model $model): bool
{
if ($model->hasRole('super_admin') && ! $user->hasRole('super_admin')) {
return false;
}
return parent::update($user, $model);
}
/**
* Deleting your own account from the panel would drop you out mid-session,
* and the last super admin must survive or the app locks itself out.
*
* @param User|Model $model
*/
public function delete(User $user, Model $model): bool
{
if ($user->id === $model->id) {
return false;
}
if ($model->hasRole('super_admin') && ! $user->hasRole('super_admin')) {
return false;
}
if ($model->hasRole('super_admin') && $this->isLastSuperAdmin($model)) {
return false;
}
return parent::delete($user, $model);
}
protected function isLastSuperAdmin(User $model): bool
{
return User::query()
->whereKeyNot($model->getKey())
->whereHas('roles', fn ($q) => $q->where('name', 'super_admin'))
->doesntExist();
}
}