stage 2 of oauth removal, switch to sanctum pat tokens completed, docs updated

This commit is contained in:
Codex Agent
2025-11-07 07:46:53 +01:00
parent 776da57ca9
commit 67affd3317
41 changed files with 124 additions and 2148 deletions

View File

@@ -1,190 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\OAuthClientResource\Pages;
use App\Models\OAuthClient;
use App\Models\RefreshToken;
use BackedEnum;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class OAuthClientResource extends Resource
{
protected static ?string $model = OAuthClient::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-key';
protected static ?int $navigationSort = 30;
public static function getNavigationGroup(): string
{
return __('admin.nav.security');
}
public static function form(Schema $form): Schema
{
return $form->schema([
Forms\Components\TextInput::make('name')
->label(__('admin.oauth.fields.name'))
->required()
->maxLength(255),
Forms\Components\TextInput::make('client_id')
->label(__('admin.oauth.fields.client_id'))
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Forms\Components\TextInput::make('client_secret')
->label(__('admin.oauth.fields.client_secret'))
->password()
->revealable()
->helperText(__('admin.oauth.hints.client_secret'))
->dehydrated(fn (?string $state): bool => filled($state))
->dehydrateStateUsing(fn (?string $state): ?string => filled($state) ? Hash::make($state) : null),
Forms\Components\Select::make('tenant_id')
->label(__('admin.oauth.fields.tenant'))
->relationship('tenant', 'name')
->searchable()
->preload()
->nullable(),
Forms\Components\Textarea::make('redirect_uris')
->label(__('admin.oauth.fields.redirect_uris'))
->rows(4)
->helperText(__('admin.oauth.hints.redirect_uris'))
->formatStateUsing(fn ($state): string => is_array($state) ? implode(PHP_EOL, $state) : (string) $state)
->dehydrateStateUsing(function (?string $state): array {
$entries = collect(preg_split('/\r\n|\r|\n/', (string) $state))
->map(fn ($uri) => trim($uri))
->filter();
return $entries->values()->all();
})
->required(),
Forms\Components\TagsInput::make('scopes')
->label(__('admin.oauth.fields.scopes'))
->placeholder('tenant:read')
->suggestions([
'tenant:read',
'tenant:write',
'tenant:admin',
])
->separator(',')
->required(),
Forms\Components\Toggle::make('is_active')
->label(__('admin.oauth.fields.is_active'))
->default(true),
Forms\Components\Textarea::make('description')
->label(__('admin.oauth.fields.description'))
->rows(3)
->columnSpanFull(),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label(__('admin.oauth.fields.name'))
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('client_id')
->label(__('admin.oauth.fields.client_id'))
->copyable()
->sortable(),
Tables\Columns\TextColumn::make('tenant.name')
->label(__('admin.oauth.fields.tenant'))
->toggleable(isToggledHiddenByDefault: true)
->sortable(),
Tables\Columns\IconColumn::make('is_active')
->label(__('admin.oauth.fields.is_active'))
->boolean()
->color(fn (bool $state): string => $state ? 'success' : 'danger'),
Tables\Columns\TextColumn::make('redirect_uris')
->label(__('admin.oauth.fields.redirect_uris'))
->formatStateUsing(fn ($state) => collect(Arr::wrap($state))->implode("\n"))
->limit(50)
->toggleable(),
Tables\Columns\TagsColumn::make('scopes')
->label(__('admin.oauth.fields.scopes'))
->separator(', ')
->limit(4),
Tables\Columns\TextColumn::make('updated_at')
->label(__('admin.oauth.fields.updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\TernaryFilter::make('is_active')
->label(__('admin.oauth.filters.is_active'))
->placeholder(__('admin.oauth.filters.any'))
->trueLabel(__('admin.oauth.filters.active'))
->falseLabel(__('admin.oauth.filters.inactive')),
Tables\Filters\SelectFilter::make('tenant_id')
->label(__('admin.oauth.fields.tenant'))
->relationship('tenant', 'name')
->searchable(),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('regenerate_secret')
->label(__('admin.oauth.actions.regenerate_secret'))
->icon('heroicon-o-arrow-path')
->requiresConfirmation()
->action(function (OAuthClient $record): void {
$plainSecret = Str::random(48);
$record->forceFill([
'client_secret' => Hash::make($plainSecret),
])->save();
Notification::make()
->title(__('admin.oauth.notifications.secret_regenerated_title'))
->body(__('admin.oauth.notifications.secret_regenerated_body', [
'secret' => $plainSecret,
]))
->success()
->send();
}),
Tables\Actions\DeleteAction::make()
->before(function (OAuthClient $record): void {
RefreshToken::query()
->where('client_id', $record->client_id)
->delete();
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make()
->before(function (Collection $records): void {
$records->each(function (OAuthClient $record) {
RefreshToken::query()
->where('client_id', $record->client_id)
->delete();
});
}),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListOAuthClients::route('/'),
'create' => Pages\CreateOAuthClient::route('/create'),
'view' => Pages\ViewOAuthClient::route('/{record}'),
'edit' => Pages\EditOAuthClient::route('/{record}/edit'),
];
}
}

View File

@@ -1,17 +0,0 @@
<?php
namespace App\Filament\Resources\OAuthClientResource\Pages;
use App\Filament\Resources\OAuthClientResource;
use Filament\Resources\Pages\CreateRecord;
class CreateOAuthClient extends CreateRecord
{
protected static string $resource = OAuthClientResource::class;
protected function getCreatedNotificationTitle(): ?string
{
return __('admin.oauth.notifications.created_title');
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Filament\Resources\OAuthClientResource\Pages;
use App\Filament\Resources\OAuthClientResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOAuthClient extends EditRecord
{
protected static string $resource = OAuthClientResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
protected function getSavedNotificationTitle(): ?string
{
return __('admin.oauth.notifications.updated_title');
}
}

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Filament\Resources\OAuthClientResource\Pages;
use App\Filament\Resources\OAuthClientResource;
use Filament\Resources\Pages\ListRecords;
class ListOAuthClients extends ListRecords
{
protected static string $resource = OAuthClientResource::class;
}

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Filament\Resources\OAuthClientResource\Pages;
use App\Filament\Resources\OAuthClientResource;
use Filament\Resources\Pages\ViewRecord;
class ViewOAuthClient extends ViewRecord
{
protected static string $resource = OAuthClientResource::class;
}

View File

@@ -1,200 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\RefreshTokenResource\Pages;
use App\Filament\Resources\RefreshTokenResource\RelationManagers\AuditsRelationManager;
use App\Models\RefreshToken;
use BackedEnum;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
use Filament\Tables\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
class RefreshTokenResource extends Resource
{
protected static ?string $model = RefreshToken::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-shield-check';
protected static ?int $navigationSort = 32;
public static function getNavigationGroup(): string
{
return __('admin.nav.security');
}
public static function getNavigationLabel(): string
{
return __('admin.refresh_tokens.menu');
}
public static function getPluralLabel(): string
{
return __('admin.refresh_tokens.menu');
}
public static function getModelLabel(): string
{
return __('admin.refresh_tokens.single');
}
public static function form(Schema $form): Schema
{
return $form->schema([]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('tenant.name')
->label(__('admin.refresh_tokens.fields.tenant'))
->searchable(),
Tables\Columns\TextColumn::make('client_id')
->label(__('admin.refresh_tokens.fields.client'))
->copyable()
->toggleable(),
Tables\Columns\TextColumn::make('ip_address')
->label(__('admin.refresh_tokens.fields.ip_address'))
->toggleable(),
Tables\Columns\TextColumn::make('user_agent')
->label(__('admin.refresh_tokens.fields.user_agent'))
->limit(40)
->tooltip(fn (RefreshToken $record) => $record->user_agent)
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
->label(__('admin.refresh_tokens.fields.created_at'))
->since()
->sortable(),
Tables\Columns\TextColumn::make('last_used_at')
->label(__('admin.refresh_tokens.fields.last_used_at'))
->since()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('expires_at')
->label(__('admin.refresh_tokens.fields.expires_at'))
->dateTime()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('status')
->label(__('admin.refresh_tokens.fields.status'))
->badge()
->formatStateUsing(function (RefreshToken $record): string {
if ($record->revoked_at) {
return __('admin.refresh_tokens.status.revoked');
}
if ($record->expires_at && $record->expires_at->isPast()) {
return __('admin.refresh_tokens.status.expired');
}
return __('admin.refresh_tokens.status.active');
})
->color(function (RefreshToken $record): string {
if ($record->revoked_at) {
return 'danger';
}
if ($record->expires_at && $record->expires_at->isPast()) {
return 'warning';
}
return 'success';
}),
Tables\Columns\TextColumn::make('revoked_reason')
->label(__('admin.refresh_tokens.fields.revoked_reason'))
->formatStateUsing(function (?string $state): ?string {
if (! $state) {
return null;
}
$key = "admin.refresh_tokens.reasons.{$state}";
$translated = __($key);
return $translated === $key ? $state : $translated;
})
->badge()
->color('gray')
->toggleable(),
])
->filters([
SelectFilter::make('status')
->label(__('admin.refresh_tokens.filters.status'))
->options([
'active' => __('admin.refresh_tokens.status.active'),
'revoked' => __('admin.refresh_tokens.status.revoked'),
'expired' => __('admin.refresh_tokens.status.expired'),
])
->query(function (Builder $query, array $data): Builder {
return match ($data['value'] ?? null) {
'revoked' => $query->whereNotNull('revoked_at'),
'expired' => $query->whereNull('revoked_at')->whereNotNull('expires_at')->where('expires_at', '<=', now()),
'active' => $query->whereNull('revoked_at')->where(function ($inner) {
$inner->whereNull('expires_at')
->orWhere('expires_at', '>', now());
}),
default => $query,
};
}),
SelectFilter::make('tenant_id')
->label(__('admin.refresh_tokens.filters.tenant'))
->relationship('tenant', 'name')
->searchable(),
])
->actions([
Tables\Actions\ViewAction::make(),
Action::make('revoke')
->label(__('admin.refresh_tokens.actions.revoke'))
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (RefreshToken $record): bool => $record->isActive())
->form([
Forms\Components\Select::make('reason')
->label(__('admin.refresh_tokens.fields.revoked_reason'))
->options([
'manual' => __('admin.refresh_tokens.reasons.manual'),
'operator' => __('admin.refresh_tokens.reasons.operator'),
])
->default('manual')
->required(),
Forms\Components\Textarea::make('note')
->label(__('admin.refresh_tokens.fields.note'))
->rows(2)
->maxLength(255),
])
->requiresConfirmation()
->action(function (RefreshToken $record, array $data): void {
$note = $data['note'] ?? null;
$record->revoke(
$data['reason'] ?? 'manual',
auth()->id(),
request(),
$note ? ['note' => $note] : []
);
}),
])
->bulkActions([]);
}
public static function getRelations(): array
{
return [
AuditsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListRefreshTokens::route('/'),
'view' => Pages\ViewRefreshToken::route('/{record}'),
];
}
}

View File

@@ -1,17 +0,0 @@
<?php
namespace App\Filament\Resources\RefreshTokenResource\Pages;
use App\Filament\Resources\RefreshTokenResource;
use Filament\Resources\Pages\ListRecords;
class ListRefreshTokens extends ListRecords
{
protected static string $resource = RefreshTokenResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -1,54 +0,0 @@
<?php
namespace App\Filament\Resources\RefreshTokenResource\Pages;
use App\Filament\Resources\RefreshTokenResource;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Pages\ViewRecord;
class ViewRefreshToken extends ViewRecord
{
protected static string $resource = RefreshTokenResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('revoke')
->label(__('admin.refresh_tokens.actions.revoke'))
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (): bool => $this->record->isActive())
->form([
Forms\Components\Select::make('reason')
->label(__('admin.refresh_tokens.fields.revoked_reason'))
->options([
'manual' => __('admin.refresh_tokens.reasons.manual'),
'operator' => __('admin.refresh_tokens.reasons.operator'),
])
->default('manual')
->required(),
Forms\Components\Textarea::make('note')
->label(__('admin.refresh_tokens.fields.note'))
->rows(2)
->maxLength(255),
])
->requiresConfirmation()
->action(function (array $data): void {
$note = $data['note'] ?? null;
$this->record->revoke(
$data['reason'] ?? 'manual',
auth()->id(),
request(),
$note ? ['note' => $note] : []
);
$this->record->refresh();
$this->notify('success', __('admin.refresh_tokens.notifications.revoked'));
}),
];
}
}

View File

@@ -1,69 +0,0 @@
<?php
namespace App\Filament\Resources\RefreshTokenResource\RelationManagers;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class AuditsRelationManager extends RelationManager
{
protected static string $relationship = 'audits';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('event')
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('created_at')
->label(__('admin.refresh_tokens.audit.performed_at'))
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('event')
->label(__('admin.refresh_tokens.audit.event'))
->badge()
->formatStateUsing(function (?string $state): ?string {
if (! $state) {
return null;
}
$key = "admin.refresh_tokens.audit.events.{$state}";
$translated = __($key);
return $translated === $key ? $state : $translated;
}),
Tables\Columns\TextColumn::make('performedBy.name')
->label(__('admin.refresh_tokens.audit.performed_by'))
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('ip_address')
->label(__('admin.refresh_tokens.audit.ip_address'))
->toggleable(),
Tables\Columns\TextColumn::make('context')
->label(__('admin.refresh_tokens.audit.context'))
->formatStateUsing(function ($state): string {
if (! is_array($state) || empty($state)) {
return '—';
}
return collect($state)
->filter(fn ($value) => filled($value))
->map(function ($value, $key) {
if (is_array($value)) {
$value = json_encode($value);
}
return "{$key}: {$value}";
})
->implode(', ');
})
->wrap()
->toggleable(),
])
->filters([])
->paginated([10, 25, 50])
->emptyStateHeading(__('admin.refresh_tokens.audit.empty.heading'))
->emptyStateDescription(__('admin.refresh_tokens.audit.empty.description'));
}
}