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:
25
app/Policies/ApplicationPolicy.php
Normal file
25
app/Policies/ApplicationPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
app/Policies/CallPolicy.php
Normal file
13
app/Policies/CallPolicy.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
class CallPolicy extends ResourcePolicy
|
||||
{
|
||||
protected function resource(): string
|
||||
{
|
||||
return 'calls';
|
||||
}
|
||||
}
|
||||
28
app/Policies/CategoryPolicy.php
Normal file
28
app/Policies/CategoryPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
13
app/Policies/ClientPolicy.php
Normal file
13
app/Policies/ClientPolicy.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
class ClientPolicy extends ResourcePolicy
|
||||
{
|
||||
protected function resource(): string
|
||||
{
|
||||
return 'clients';
|
||||
}
|
||||
}
|
||||
33
app/Policies/MediaPolicy.php
Normal file
33
app/Policies/MediaPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
29
app/Policies/MemberPolicy.php
Normal file
29
app/Policies/MemberPolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
41
app/Policies/MissingUrlPolicy.php
Normal file
41
app/Policies/MissingUrlPolicy.php
Normal 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');
|
||||
}
|
||||
}
|
||||
43
app/Policies/PagePolicy.php
Normal file
43
app/Policies/PagePolicy.php
Normal 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');
|
||||
}
|
||||
}
|
||||
25
app/Policies/ProjectPolicy.php
Normal file
25
app/Policies/ProjectPolicy.php
Normal 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');
|
||||
}
|
||||
}
|
||||
13
app/Policies/RedirectPolicy.php
Normal file
13
app/Policies/RedirectPolicy.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
class RedirectPolicy extends ResourcePolicy
|
||||
{
|
||||
protected function resource(): string
|
||||
{
|
||||
return 'redirects';
|
||||
}
|
||||
}
|
||||
25
app/Policies/RepositoryPolicy.php
Normal file
25
app/Policies/RepositoryPolicy.php
Normal 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');
|
||||
}
|
||||
}
|
||||
72
app/Policies/ResourcePolicy.php
Normal file
72
app/Policies/ResourcePolicy.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
44
app/Policies/RolePolicy.php
Normal file
44
app/Policies/RolePolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
13
app/Policies/ServicePolicy.php
Normal file
13
app/Policies/ServicePolicy.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
class ServicePolicy extends ResourcePolicy
|
||||
{
|
||||
protected function resource(): string
|
||||
{
|
||||
return 'services';
|
||||
}
|
||||
}
|
||||
13
app/Policies/TalkPolicy.php
Normal file
13
app/Policies/TalkPolicy.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
class TalkPolicy extends ResourcePolicy
|
||||
{
|
||||
protected function resource(): string
|
||||
{
|
||||
return 'talks';
|
||||
}
|
||||
}
|
||||
34
app/Policies/TechnologyPolicy.php
Normal file
34
app/Policies/TechnologyPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
13
app/Policies/TestimonialPolicy.php
Normal file
13
app/Policies/TestimonialPolicy.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
class TestimonialPolicy extends ResourcePolicy
|
||||
{
|
||||
protected function resource(): string
|
||||
{
|
||||
return 'testimonials';
|
||||
}
|
||||
}
|
||||
74
app/Policies/UserPolicy.php
Normal file
74
app/Policies/UserPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user