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:
74
app/Http/Controllers/Api/V1/CategoryController.php
Normal file
74
app/Http/Controllers/Api/V1/CategoryController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\CategoryRequest;
|
||||
use App\Http\Resources\CategoryResource;
|
||||
use App\Models\Category;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', Category::class);
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$categories = Category::query()
|
||||
->withCount('pages')
|
||||
->when($request->has('is_visible'), fn ($q) => $q->where('is_visible', $request->boolean('is_visible')))
|
||||
->when($request->filled('search'), fn ($q) => $q->where("name->{$locale}", 'like', '%'.$request->string('search').'%'))
|
||||
->orderBy('position')
|
||||
->orderBy("name->{$locale}")
|
||||
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||
->withQueryString();
|
||||
|
||||
return CategoryResource::collection($categories);
|
||||
}
|
||||
|
||||
public function store(CategoryRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Category::class);
|
||||
|
||||
$category = new Category;
|
||||
$category->fill(TranslatablePayload::merge($category, $request->validated(), $category->translatable));
|
||||
$category->save();
|
||||
|
||||
return CategoryResource::make($category)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Category $category): CategoryResource
|
||||
{
|
||||
$this->authorize('view', $category);
|
||||
|
||||
return CategoryResource::make($category->loadCount('pages'));
|
||||
}
|
||||
|
||||
public function update(CategoryRequest $request, Category $category): CategoryResource
|
||||
{
|
||||
$this->authorize('update', $category);
|
||||
|
||||
$category->fill(TranslatablePayload::merge($category, $request->validated(), $category->translatable));
|
||||
$category->save();
|
||||
|
||||
return CategoryResource::make($category->loadCount('pages'));
|
||||
}
|
||||
|
||||
public function destroy(Category $category): JsonResponse
|
||||
{
|
||||
// CategoryPolicy refuses while the category still holds pages, so this
|
||||
// answers 403 rather than orphaning content.
|
||||
$this->authorize('delete', $category);
|
||||
|
||||
$category->delete();
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
}
|
||||
26
app/Http/Controllers/Api/V1/MeController.php
Normal file
26
app/Http/Controllers/Api/V1/MeController.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\UserResource;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/v1/me — who the bearer token belongs to, and what they may do here.
|
||||
*
|
||||
* The identity part mirrors the OAuth server's /api/user. The `permissions`
|
||||
* array is this application's own answer, and is what a client should drive its
|
||||
* UI from: the identity server does not know about local roles.
|
||||
*/
|
||||
class MeController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): UserResource
|
||||
{
|
||||
return UserResource::make(
|
||||
$request->user()->load('roles')
|
||||
);
|
||||
}
|
||||
}
|
||||
138
app/Http/Controllers/Api/V1/PageController.php
Normal file
138
app/Http/Controllers/Api/V1/PageController.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\PageStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\PageRequest;
|
||||
use App\Http\Resources\PageResource;
|
||||
use App\Models\Page;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/pages
|
||||
*
|
||||
* Filters: status, category_id, is_featured, search, translated_in, trashed
|
||||
* Sorting: sort=-published_at (prefix with "-" for descending)
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', Page::class);
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$pages = Page::query()
|
||||
->with(['category', 'author'])
|
||||
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||
->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id')))
|
||||
->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured')))
|
||||
// Search inside the active locale's translation rather than across
|
||||
// the whole JSON blob, so a Spanish query cannot match English text.
|
||||
->when($request->filled('search'), fn ($q) => $q->where(
|
||||
fn ($sub) => $sub
|
||||
->where("title->{$locale}", 'like', '%'.$request->string('search').'%')
|
||||
->orWhere("excerpt->{$locale}", 'like', '%'.$request->string('search').'%')
|
||||
))
|
||||
->when($request->filled('translated_in'), fn ($q) => $q->translatedIn((string) $request->string('translated_in')))
|
||||
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||
// Callers without the publish permission only ever see live content.
|
||||
->unless($request->user()->can('pages.publish'), fn ($q) => $q->published())
|
||||
->tap(fn ($q) => $this->applySort($q, $request))
|
||||
->paginate(perPage: min($request->integer('per_page', 15), 100))
|
||||
->withQueryString();
|
||||
|
||||
return PageResource::collection($pages);
|
||||
}
|
||||
|
||||
public function store(PageRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Page::class);
|
||||
|
||||
$page = new Page;
|
||||
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
|
||||
$page->author_id ??= $request->user()->id;
|
||||
|
||||
// A page created as published with no explicit date goes live now.
|
||||
if ($page->status === PageStatus::Published && $page->published_at === null) {
|
||||
$page->published_at = now();
|
||||
}
|
||||
|
||||
$page->save();
|
||||
|
||||
return PageResource::make($page->load(['category', 'author']))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Request $request, Page $page): PageResource
|
||||
{
|
||||
$this->authorize('view', $page);
|
||||
|
||||
return PageResource::make($page->load(['category', 'author']));
|
||||
}
|
||||
|
||||
public function update(PageRequest $request, Page $page): PageResource
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
|
||||
|
||||
if ($page->status === PageStatus::Published && $page->published_at === null) {
|
||||
$page->published_at = now();
|
||||
}
|
||||
|
||||
$page->save();
|
||||
|
||||
return PageResource::make($page->load(['category', 'author']));
|
||||
}
|
||||
|
||||
public function destroy(Page $page): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $page);
|
||||
|
||||
$page->delete();
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
public function restore(Page $page): PageResource
|
||||
{
|
||||
$this->authorize('restore', $page);
|
||||
|
||||
$page->restore();
|
||||
|
||||
return PageResource::make($page->load(['category', 'author']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist of sortable columns. Anything else is ignored rather than
|
||||
* passed to the query builder.
|
||||
*
|
||||
* @param Builder<Page> $query
|
||||
*/
|
||||
protected function applySort(Builder $query, Request $request): void
|
||||
{
|
||||
$sort = (string) $request->string('sort', '-created_at');
|
||||
$direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
|
||||
$column = ltrim($sort, '-');
|
||||
|
||||
$sortable = ['created_at', 'updated_at', 'published_at', 'position', 'status'];
|
||||
|
||||
// Sorting by a translatable column targets the active locale's key.
|
||||
if ($column === 'title') {
|
||||
$query->orderBy('title->'.app()->getLocale(), $direction);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->orderBy(in_array($column, $sortable, true) ? $column : 'created_at', $direction);
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Api/V1/RoleController.php
Normal file
50
app/Http/Controllers/Api/V1/RoleController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\RoleResource;
|
||||
use App\Support\Permissions;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
/**
|
||||
* Read-only view of the local authorisation model, so clients can render
|
||||
* permission-aware UI without hard-coding role names.
|
||||
*/
|
||||
class RoleController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', Role::class);
|
||||
|
||||
$roles = Role::query()
|
||||
->with('permissions')
|
||||
->withCount('users')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return RoleResource::collection($roles);
|
||||
}
|
||||
|
||||
public function show(Role $role): RoleResource
|
||||
{
|
||||
$this->authorize('view', $role);
|
||||
|
||||
return RoleResource::make($role->load('permissions')->loadCount('users'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The full permission catalogue, grouped by resource — useful for building
|
||||
* a role editor outside the panel.
|
||||
*/
|
||||
public function catalogue(): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', Role::class);
|
||||
|
||||
return response()->json(['data' => Permissions::grouped()]);
|
||||
}
|
||||
}
|
||||
97
app/Http/Controllers/Api/V1/TokenController.php
Normal file
97
app/Http/Controllers/Api/V1/TokenController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Actions\Auth\SyncPireonUser;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Pireon\PireonClient;
|
||||
use App\Services\Pireon\PireonException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/token
|
||||
*
|
||||
* A thin, rate-limited proxy over the identity server's password and
|
||||
* refresh_token grants, so first-party clients (mobile apps, test suites) can
|
||||
* obtain a token without embedding the OAuth server's URL and client secret.
|
||||
*
|
||||
* The panel's sign-in form uses the same grant directly, so
|
||||
* `PIREON_PASSWORD_GRANT=false` closes both doors at once.
|
||||
*/
|
||||
class TokenController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PireonClient $client,
|
||||
protected SyncPireonUser $sync,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'grant_type' => ['required', 'in:password,refresh_token'],
|
||||
'username' => ['required_if:grant_type,password', 'string'],
|
||||
'password' => ['required_if:grant_type,password', 'string'],
|
||||
'refresh_token' => ['required_if:grant_type,refresh_token', 'string'],
|
||||
'scope' => ['sometimes', 'string'],
|
||||
]);
|
||||
|
||||
$scopes = isset($data['scope'])
|
||||
? array_values(array_filter(explode(' ', $data['scope'])))
|
||||
: null;
|
||||
|
||||
try {
|
||||
$token = $data['grant_type'] === 'password'
|
||||
? $this->client->requestPasswordToken($data['username'], $data['password'], $scopes)
|
||||
: $this->client->refresh($data['refresh_token']);
|
||||
|
||||
$identity = $this->client->identity($token->accessToken);
|
||||
} catch (PireonException $e) {
|
||||
// An identity server that failed to answer has said nothing about
|
||||
// these credentials, so it must not be reported as though it had.
|
||||
if (! $e->isRejection()) {
|
||||
return response()->json([
|
||||
'message' => __('pireon.server_unavailable'),
|
||||
], Response::HTTP_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// Otherwise a validation error rather than the upstream body, so
|
||||
// we never echo details of the identity server back to a caller
|
||||
// probing for valid accounts.
|
||||
throw ValidationException::withMessages([
|
||||
'username' => [__('auth.failed')],
|
||||
])->status($e->status === 401 ? 401 : 422);
|
||||
}
|
||||
|
||||
if (! $identity->isUsable()) {
|
||||
return response()->json([
|
||||
'message' => $identity->isApproved()
|
||||
? __('pireon.inactive')
|
||||
: __('pireon.pending_approval'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Provision the local account now, so the very first API call after
|
||||
// this one already has roles and a user row to authorise against.
|
||||
$user = $this->sync->handle($identity);
|
||||
|
||||
return response()->json([
|
||||
'access_token' => $token->accessToken,
|
||||
'refresh_token' => $token->refreshToken,
|
||||
'token_type' => $token->tokenType,
|
||||
'expires_at' => $token->expiresAt?->toIso8601String(),
|
||||
'scopes' => $token->scopes,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'roles' => $user->roles->pluck('name')->values(),
|
||||
'permissions' => $user->getAllPermissions()->pluck('name')->values(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/Api/V1/UserController.php
Normal file
83
app/Http/Controllers/Api/V1/UserController.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
/**
|
||||
* Local user administration.
|
||||
*
|
||||
* Only fields this application owns are writable: the local role assignment,
|
||||
* the active flag, and the UI locale. Names and emails belong to the identity
|
||||
* server and are refreshed from it on every login, so editing them here would
|
||||
* be undone — change those at oauth.pireon.dev instead.
|
||||
*/
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', User::class);
|
||||
|
||||
$users = User::query()
|
||||
->with('roles')
|
||||
->when($request->filled('search'), fn ($q) => $q->where(
|
||||
fn ($sub) => $sub
|
||||
->where('name', 'like', '%'.$request->string('search').'%')
|
||||
->orWhere('email', 'like', '%'.$request->string('search').'%')
|
||||
))
|
||||
->when($request->has('is_active'), fn ($q) => $q->where('is_active', $request->boolean('is_active')))
|
||||
->when($request->filled('role'), fn ($q) => $q->whereHas(
|
||||
'roles',
|
||||
fn ($sub) => $sub->where('name', $request->string('role'))
|
||||
))
|
||||
->orderBy('name')
|
||||
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||
->withQueryString();
|
||||
|
||||
return UserResource::collection($users);
|
||||
}
|
||||
|
||||
public function show(User $user): UserResource
|
||||
{
|
||||
$this->authorize('view', $user);
|
||||
|
||||
return UserResource::make($user->load('roles'));
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): UserResource
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$data = $request->validate([
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'locale' => ['sometimes', 'nullable', Rule::in(array_keys(config('locales.supported')))],
|
||||
'roles' => ['sometimes', 'array'],
|
||||
'roles.*' => [Rule::exists('roles', 'name')],
|
||||
]);
|
||||
|
||||
if (array_key_exists('roles', $data)) {
|
||||
$this->authorize('update', Role::class);
|
||||
|
||||
// Only a super admin may hand out super_admin, and nobody may
|
||||
// strip their own privileges out from under themselves.
|
||||
if (in_array('super_admin', $data['roles'], true) && ! $request->user()->hasRole('super_admin')) {
|
||||
abort(403, __('pireon.cannot_grant_super_admin'));
|
||||
}
|
||||
|
||||
$user->syncRoles($data['roles']);
|
||||
unset($data['roles']);
|
||||
}
|
||||
|
||||
$user->fill($data)->save();
|
||||
|
||||
return UserResource::make($user->fresh()->load('roles'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user