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:
178
app/Filament/Pages/Auth/PireonLogin.php
Normal file
178
app/Filament/Pages/Auth/PireonLogin.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Auth;
|
||||
|
||||
use App\Actions\Auth\SyncPireonUser;
|
||||
use App\Services\Pireon\AccessToken;
|
||||
use App\Services\Pireon\PireonClient;
|
||||
use App\Services\Pireon\PireonException;
|
||||
use App\Services\Pireon\TokenStore;
|
||||
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
|
||||
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
||||
use Filament\Auth\Pages\Login;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* The panel's login screen: an ordinary e-mail and password form.
|
||||
*
|
||||
* The identity server is still the only thing that decides whether those
|
||||
* credentials are correct — there are no local passwords, and `users` has no
|
||||
* usable password column — but the person signing in never sees that. They
|
||||
* type their credentials here and land in the panel.
|
||||
*
|
||||
* The cost of that convenience is stated plainly because it is real: this
|
||||
* application now handles the password rather than never seeing it. It is sent
|
||||
* straight to the identity server over the password grant and is never stored,
|
||||
* logged, or written anywhere; but a compromise here would expose credentials
|
||||
* to the identity server, which the redirect flow made impossible. The
|
||||
* Authorization Code + PKCE routes are still in place
|
||||
* ([`PireonAuthController`](app/Http/Controllers/Auth/PireonAuthController.php))
|
||||
* for anyone who wants that trade back.
|
||||
*/
|
||||
class PireonLogin extends Login
|
||||
{
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return __('pireon.sign_in');
|
||||
}
|
||||
|
||||
public function getHeading(): string|Htmlable
|
||||
{
|
||||
return __('pireon.sign_in');
|
||||
}
|
||||
|
||||
public function getSubheading(): string|Htmlable|null
|
||||
{
|
||||
return __('pireon.sign_in_intro');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filament's own flow checks the credentials against the local user table.
|
||||
* There are no local passwords here, so the whole of it is replaced: the
|
||||
* identity server is asked instead, and only what it says is trusted.
|
||||
*/
|
||||
public function authenticate(): ?LoginResponse
|
||||
{
|
||||
try {
|
||||
// Filament's own limiter, keyed by IP: five attempts, then a wait.
|
||||
// The identity server has its own; this stops us spending it.
|
||||
$this->rateLimit(5);
|
||||
} catch (TooManyRequestsException $exception) {
|
||||
$this->getRateLimitedNotification($exception)?->send();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $this->form->getState();
|
||||
|
||||
$this->assertUsable();
|
||||
|
||||
$token = $this->exchange((string) $data['email'], (string) $data['password']);
|
||||
|
||||
$client = app(PireonClient::class);
|
||||
|
||||
try {
|
||||
$identity = $client->identity($token->accessToken);
|
||||
} catch (PireonException $e) {
|
||||
$this->fail($e);
|
||||
}
|
||||
|
||||
// Said only once the credentials were right, so this cannot be used to
|
||||
// find out which addresses have an account.
|
||||
if (! $identity->isUsable()) {
|
||||
throw ValidationException::withMessages([
|
||||
'data.email' => $identity->isApproved()
|
||||
? __('pireon.inactive')
|
||||
: __('pireon.pending_approval'),
|
||||
]);
|
||||
}
|
||||
|
||||
$user = app(SyncPireonUser::class)->handle($identity);
|
||||
|
||||
// Being a valid account upstream is not the same as being allowed in
|
||||
// here: roles are this application's business.
|
||||
if (! $user->canAccessPanel(Filament::getCurrentOrDefaultPanel())) {
|
||||
throw ValidationException::withMessages([
|
||||
'data.email' => __('pireon.no_permission'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Rotate the session id so a fixated pre-login session cannot be
|
||||
// reused now that it carries an authenticated identity.
|
||||
session()->regenerate();
|
||||
|
||||
Auth::login($user, (bool) ($data['remember'] ?? false));
|
||||
|
||||
app(TokenStore::class)->put($token);
|
||||
|
||||
if ($user->locale) {
|
||||
session()->put(config('locales.session_key'), $user->locale);
|
||||
}
|
||||
|
||||
return app(LoginResponse::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade the credentials for a token upstream.
|
||||
*
|
||||
* Any refusal answers with the same message. The identity server
|
||||
* distinguishes an unknown address from a wrong password; repeating that
|
||||
* here would turn the form into a way of finding out who has an account.
|
||||
*/
|
||||
protected function exchange(string $email, #[SensitiveParameter] string $password): AccessToken
|
||||
{
|
||||
try {
|
||||
return app(PireonClient::class)->requestPasswordToken($email, $password);
|
||||
} catch (PireonException $e) {
|
||||
$this->fail($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A misconfiguration is an operator's problem, not a visitor's, so it says
|
||||
* what is actually wrong instead of "those credentials are incorrect".
|
||||
*/
|
||||
protected function assertUsable(): void
|
||||
{
|
||||
if (! app(PireonClient::class)->isConfigured()) {
|
||||
throw ValidationException::withMessages([
|
||||
'data.email' => __('pireon.not_configured_body', ['server' => (string) config('pireon.server')]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! config('pireon.password_grant_enabled')) {
|
||||
throw ValidationException::withMessages([
|
||||
'data.email' => __('pireon.password_grant_disabled'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function fail(PireonException $e): never
|
||||
{
|
||||
// The address is logged, the password never is — it does not leave the
|
||||
// request it arrived in.
|
||||
Log::warning('Pireon sign-in failed', [
|
||||
'message' => $e->getMessage(),
|
||||
'status' => $e->status,
|
||||
]);
|
||||
|
||||
// Only say "those credentials are wrong" when that is what happened.
|
||||
// A server that answered 500, or did not answer at all, has said
|
||||
// nothing about the password, and reporting otherwise sends whoever is
|
||||
// signing in to look for a problem that is not theirs.
|
||||
if (! $e->isRejection()) {
|
||||
throw ValidationException::withMessages([
|
||||
'data.email' => __('pireon.server_unavailable'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->throwFailureValidationException();
|
||||
}
|
||||
}
|
||||
147
app/Filament/Pages/SiteHealth.php
Normal file
147
app/Filament/Pages/SiteHealth.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\HealthReport;
|
||||
use App\Support\Health\Severity;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Everything wrong with the public site, in one place.
|
||||
*
|
||||
* This page exists because the site is somewhere else. A missing translation,
|
||||
* two records answering at the same URL, an image whose file is gone: none of
|
||||
* it is visible where the content is edited. It shows up on the frontend,
|
||||
* usually late, and usually to a visitor rather than to us.
|
||||
*/
|
||||
class SiteHealth extends Page
|
||||
{
|
||||
protected string $view = 'filament.pages.site-health';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
|
||||
|
||||
protected static ?int $navigationSort = 88;
|
||||
|
||||
/**
|
||||
* Not a public Livewire property: a Finding is a plain object, and
|
||||
* Livewire can only carry types it knows how to serialise. It is
|
||||
* recomputed per render instead, which is what opening this page is for.
|
||||
*
|
||||
* @var Collection<int, Finding>|null
|
||||
*/
|
||||
protected ?Collection $findings = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
// Anyone who edits content benefits from seeing what is wrong with it.
|
||||
// Individual checks hide themselves behind their own permission.
|
||||
return Auth::user()?->can('panel.access') ?? false;
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|UnitEnum|null
|
||||
{
|
||||
return __('panel.groups.administration');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('health.title');
|
||||
}
|
||||
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return __('health.title');
|
||||
}
|
||||
|
||||
public function getSubheading(): string|Htmlable|null
|
||||
{
|
||||
return __('health.subheading');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
public function findings(): Collection
|
||||
{
|
||||
return $this->findings ??= app(HealthReport::class)->runFor(Auth::user());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('refresh')
|
||||
->label(__('health.actions.refresh'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->refreshFindings();
|
||||
|
||||
Notification::make()
|
||||
->title(__('health.refreshed'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function refreshFindings(): void
|
||||
{
|
||||
// The badge is cached so that drawing it does not run ten checks on
|
||||
// every page of the panel; opening this page is the one moment we
|
||||
// definitely want fresh answers.
|
||||
app(HealthReport::class)->forget(Auth::user());
|
||||
|
||||
$this->findings = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
public function problems(): Collection
|
||||
{
|
||||
return $this->findings()->filter(fn (Finding $f) => $f->severity->isProblem());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
public function passing(): Collection
|
||||
{
|
||||
return $this->findings()->reject(fn (Finding $f) => $f->severity->isProblem());
|
||||
}
|
||||
|
||||
public function hasErrors(): bool
|
||||
{
|
||||
return $this->problems()->contains(fn (Finding $f) => $f->severity === Severity::Error);
|
||||
}
|
||||
|
||||
/**
|
||||
* A count in the sidebar, because a diagnostics page nobody opens
|
||||
* diagnoses nothing.
|
||||
*/
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$problems = app(HealthReport::class)->problemCount(Auth::user());
|
||||
|
||||
return $problems > 0 ? (string) $problems : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'warning';
|
||||
}
|
||||
}
|
||||
216
app/Filament/Pages/SiteMaintenance.php
Normal file
216
app/Filament/Pages/SiteMaintenance.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Support\Maintenance;
|
||||
use BackedEnum;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Puts the **public site** into maintenance, without touching the panel.
|
||||
*
|
||||
* The distinction is the whole feature: `artisan down` would lock out the
|
||||
* collective as well, exactly when they need to be fixing whatever went wrong.
|
||||
* Here the team keeps editing, repositories keep syncing, and only visitors of
|
||||
* the public site see the notice.
|
||||
*/
|
||||
class SiteMaintenance extends Page
|
||||
{
|
||||
protected string $view = 'filament.pages.site-maintenance';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedWrenchScrewdriver;
|
||||
|
||||
protected static ?int $navigationSort = 90;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $data = [];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return Auth::user()?->can('settings.view') ?? false;
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|UnitEnum|null
|
||||
{
|
||||
return __('panel.groups.administration');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('maintenance.title');
|
||||
}
|
||||
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return __('maintenance.title');
|
||||
}
|
||||
|
||||
public function getSubheading(): string|Htmlable|null
|
||||
{
|
||||
return __('maintenance.subheading');
|
||||
}
|
||||
|
||||
/**
|
||||
* A red badge in the sidebar while the site is down — this is not
|
||||
* something anyone should be able to forget about.
|
||||
*/
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return Maintenance::isEnabled() ? __('maintenance.badge') : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'message' => Maintenance::messages(),
|
||||
'until' => Maintenance::until()?->toDateTimeString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->statePath('data')
|
||||
->components([
|
||||
Section::make(__('maintenance.notice'))
|
||||
->description(__('maintenance.notice_hint'))
|
||||
->schema([
|
||||
Tabs::make('message')
|
||||
->tabs(collect(config('locales.supported'))
|
||||
->map(fn (array $meta, string $locale) => Tab::make($locale)
|
||||
->label(trim(($meta['flag'] ?? '').' '.($meta['native'] ?? $locale)))
|
||||
->schema([
|
||||
Textarea::make("message.{$locale}")
|
||||
->hiddenLabel()
|
||||
->rows(3)
|
||||
->placeholder(__('maintenance.default_message', [], $locale))
|
||||
->maxLength(500),
|
||||
]))
|
||||
->values()
|
||||
->all())
|
||||
->columnSpanFull(),
|
||||
|
||||
DateTimePicker::make('until')
|
||||
->label(__('maintenance.until'))
|
||||
->helperText(__('maintenance.until_hint'))
|
||||
->seconds(false)
|
||||
->minDate(now()),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('enable')
|
||||
->label(__('maintenance.enable'))
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalDescription(__('maintenance.enable_confirm'))
|
||||
->visible(fn () => ! Maintenance::isEnabled() && $this->canToggle())
|
||||
->action(function (): void {
|
||||
$data = $this->form->getState();
|
||||
|
||||
Maintenance::enable(
|
||||
message: array_filter($data['message'] ?? [], fn ($v) => filled($v)),
|
||||
until: filled($data['until'] ?? null) ? CarbonImmutable::parse($data['until']) : null,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title(__('maintenance.enabled_notice'))
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('disable')
|
||||
->label(__('maintenance.disable'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn () => Maintenance::isEnabled() && $this->canToggle())
|
||||
->action(function (): void {
|
||||
Maintenance::disable();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('maintenance.disabled_notice'))
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('save')
|
||||
->label(__('maintenance.save_message'))
|
||||
->color('gray')
|
||||
->visible(fn () => $this->canToggle())
|
||||
->action(function (): void {
|
||||
$data = $this->form->getState();
|
||||
$messages = array_filter($data['message'] ?? [], fn ($v) => filled($v));
|
||||
|
||||
// Saving the wording must not itself take the site down.
|
||||
if (Maintenance::isEnabled()) {
|
||||
Maintenance::enable(
|
||||
message: $messages,
|
||||
until: filled($data['until'] ?? null) ? CarbonImmutable::parse($data['until']) : null,
|
||||
);
|
||||
} else {
|
||||
Setting::set(Maintenance::KEY, [
|
||||
...Maintenance::state(),
|
||||
'enabled' => false,
|
||||
'message' => $messages,
|
||||
]);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('maintenance.message_saved'))
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return Maintenance::isEnabled();
|
||||
}
|
||||
|
||||
public function startedAt(): ?string
|
||||
{
|
||||
return Maintenance::startedAt()?->diffForHumans();
|
||||
}
|
||||
|
||||
public function endsAt(): ?string
|
||||
{
|
||||
return Maintenance::until()?->isoFormat('LLL');
|
||||
}
|
||||
|
||||
public function canToggle(): bool
|
||||
{
|
||||
return Auth::user()?->can('settings.maintenance') ?? false;
|
||||
}
|
||||
}
|
||||
183
app/Filament/Pages/Trash.php
Normal file
183
app/Filament/Pages/Trash.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Category;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\Testimonial;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page as FilamentPage;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Everything deleted, in one place.
|
||||
*
|
||||
* Soft deletes are everywhere, but recovering something meant remembering
|
||||
* which resource it had been in and then turning on a filter — which is a lot
|
||||
* to ask of somebody who has just deleted the wrong thing and is looking for
|
||||
* the undo button.
|
||||
*/
|
||||
class Trash extends FilamentPage
|
||||
{
|
||||
protected string $view = 'filament.pages.trash';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTrash;
|
||||
|
||||
protected static ?int $navigationSort = 87;
|
||||
|
||||
/**
|
||||
* Every model that can be recovered, and the permission that governs it.
|
||||
*
|
||||
* @var array<class-string<Model>, string>
|
||||
*/
|
||||
public const RECOVERABLE = [
|
||||
Page::class => 'pages',
|
||||
Project::class => 'projects',
|
||||
Service::class => 'services',
|
||||
Member::class => 'members',
|
||||
Client::class => 'clients',
|
||||
Testimonial::class => 'testimonials',
|
||||
Category::class => 'categories',
|
||||
|
||||
// The inbox soft-deletes too, and it is gated by its own permission
|
||||
// below — a viewer never sees a stranger's message here either.
|
||||
Application::class => 'applications',
|
||||
];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return Auth::user()?->can('panel.access') ?? false;
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|UnitEnum|null
|
||||
{
|
||||
return __('panel.groups.administration');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('panel.trash.title');
|
||||
}
|
||||
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return __('panel.trash.title');
|
||||
}
|
||||
|
||||
public function getSubheading(): string|Htmlable|null
|
||||
{
|
||||
return __('panel.trash.subheading');
|
||||
}
|
||||
|
||||
/**
|
||||
* What is in the bin, grouped by what it is — and only the kinds this user
|
||||
* is allowed to see in the first place.
|
||||
*
|
||||
* @return Collection<string, array{label: string, records: Collection<int, Model>}>
|
||||
*/
|
||||
public function groups(): Collection
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return collect(self::RECOVERABLE)
|
||||
->filter(fn (string $resource) => $user?->can("{$resource}.view_any") ?? false)
|
||||
->map(fn (string $resource, string $model) => [
|
||||
'model' => $model,
|
||||
'label' => __("panel.{$resource}.plural"),
|
||||
'records' => $model::onlyTrashed()->latest('deleted_at')->limit(25)->get(),
|
||||
])
|
||||
->filter(fn (array $group) => $group['records']->isNotEmpty());
|
||||
}
|
||||
|
||||
public function total(): int
|
||||
{
|
||||
return $this->groups()->sum(fn (array $group) => $group['records']->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* Put one thing back.
|
||||
*/
|
||||
public function restoreRecord(string $model, int|string $id): void
|
||||
{
|
||||
$record = $this->find($model, $id);
|
||||
|
||||
if (! $record || ! Auth::user()?->can('restore', $record)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->restore();
|
||||
|
||||
Notification::make()
|
||||
->title(__('panel.trash.restored'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase one thing for good.
|
||||
*/
|
||||
public function eraseRecord(string $model, int|string $id): void
|
||||
{
|
||||
$record = $this->find($model, $id);
|
||||
|
||||
if (! $record || ! Auth::user()?->can('forceDelete', $record)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->forceDelete();
|
||||
|
||||
Notification::make()
|
||||
->title(__('panel.trash.erased'))
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* How a record is described here, whatever kind it is.
|
||||
*/
|
||||
public function labelFor(Model $record): string
|
||||
{
|
||||
foreach (['name', 'title', 'author_name', 'full_name'] as $attribute) {
|
||||
$value = $record->getAttribute($attribute);
|
||||
|
||||
if (blank($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return is_array($value)
|
||||
? (string) ($value[config('locales.default')] ?? reset($value))
|
||||
: (string) $value;
|
||||
}
|
||||
|
||||
return '#'.$record->getKey();
|
||||
}
|
||||
|
||||
protected function find(string $model, int|string $id): ?Model
|
||||
{
|
||||
// Only the models this page declares: an id and a class name arriving
|
||||
// from the browser must not be able to reach anything else.
|
||||
if (! array_key_exists($model, self::RECOVERABLE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $model::onlyTrashed()->find($id);
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user