From e0e9723b11e16149959d8d1c46bb0b602cc79cd6 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 28 Jan 2026 21:24:37 +0100 Subject: [PATCH] Add support API token management to profile --- .../SuperAdmin/Pages/Auth/EditProfile.php | 53 ++-- .../Widgets/SupportApiTokenManager.php | 280 ++++++++++++++++++ .../Filament/SuperAdminPanelProvider.php | 2 +- .../SuperAdminSupportApiTokensTest.php | 66 +++++ 4 files changed, 378 insertions(+), 23 deletions(-) create mode 100644 app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php create mode 100644 tests/Feature/SuperAdminSupportApiTokensTest.php diff --git a/app/Filament/SuperAdmin/Pages/Auth/EditProfile.php b/app/Filament/SuperAdmin/Pages/Auth/EditProfile.php index 17c32a1..352e27a 100644 --- a/app/Filament/SuperAdmin/Pages/Auth/EditProfile.php +++ b/app/Filament/SuperAdmin/Pages/Auth/EditProfile.php @@ -2,40 +2,49 @@ namespace App\Filament\SuperAdmin\Pages\Auth; +use App\Filament\SuperAdmin\Widgets\SupportApiTokenManager; use Filament\Auth\Pages\EditProfile as BaseEditProfile; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; +use Filament\Schemas\Components\Livewire; +use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; -use Illuminate\Support\Facades\Log; class EditProfile extends BaseEditProfile { - public function mount(): void - { - Log::info('EditProfile class loaded for superadmin'); - parent::mount(); - } - public function form(Schema $schema): Schema { return $schema ->schema([ - $this->getNameFormComponent(), - $this->getEmailFormComponent(), - TextInput::make('username') - ->required() - ->unique(ignoreRecord: true) - ->maxLength(255), - Select::make('preferred_locale') - ->options([ - 'de' => 'Deutsch', - 'en' => 'English', + Section::make('Profile') + ->schema([ + $this->getNameFormComponent(), + $this->getEmailFormComponent(), + TextInput::make('username') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255), + Select::make('preferred_locale') + ->options([ + 'de' => 'Deutsch', + 'en' => 'English', + ]) + ->default('de') + ->required(), ]) - ->default('de') - ->required(), - $this->getPasswordFormComponent(), - $this->getPasswordConfirmationFormComponent(), - $this->getCurrentPasswordFormComponent(), + ->columns(2), + Section::make('Security') + ->schema([ + $this->getPasswordFormComponent(), + $this->getPasswordConfirmationFormComponent(), + $this->getCurrentPasswordFormComponent(), + ]) + ->columns(1), + Section::make('Support API Tokens') + ->description('Manage bearer tokens for external support tooling.') + ->schema([ + Livewire::make(SupportApiTokenManager::class), + ]), ]); } } diff --git a/app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php b/app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php new file mode 100644 index 0000000..148f46e --- /dev/null +++ b/app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php @@ -0,0 +1,280 @@ +heading('Support API Tokens') + ->query(fn (): Builder => $this->getTokenQuery()) + ->defaultSort('created_at', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('name') + ->label('Name') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('abilities') + ->label('Abilities') + ->formatStateUsing(fn ($state): string => $this->formatAbilities($state)) + ->wrap(), + Tables\Columns\TextColumn::make('last_used_at') + ->label('Last used') + ->since() + ->placeholder('—'), + Tables\Columns\TextColumn::make('expires_at') + ->label('Expires') + ->dateTime('Y-m-d H:i') + ->placeholder('—'), + Tables\Columns\TextColumn::make('created_at') + ->label('Created') + ->since(), + ]) + ->headerActions([ + Action::make('create_support_token') + ->label('Create token') + ->icon('heroicon-o-key') + ->form([ + TextInput::make('name') + ->label('Token name') + ->default($this->defaultTokenName()) + ->required() + ->maxLength(255) + ->helperText('Existing tokens with the same name will be revoked.'), + CheckboxList::make('abilities') + ->label('Abilities') + ->options($this->abilityOptions()) + ->columns(2) + ->required() + ->default($this->defaultAbilities()), + DateTimePicker::make('expires_at') + ->label('Expires at') + ->displayFormat('Y-m-d H:i') + ->seconds(false), + ]) + ->action(function (array $data): void { + $user = $this->getUser(); + + if (! $user) { + return; + } + + $name = $this->normalizeTokenName($data['name'] ?? null); + $abilities = $this->normalizeAbilities($data['abilities'] ?? []); + $expiresAt = $this->normalizeExpiresAt($data['expires_at'] ?? null); + + $user->tokens()->where('name', $name)->delete(); + + $token = $user->createToken($name, $abilities, $expiresAt); + + $this->recordTokenCreated($token, $abilities, $user); + + Notification::make() + ->success() + ->title('Token created') + ->body('Copy this token now. It will not be shown again: '.$token->plainTextToken) + ->persistent() + ->send(); + }), + ]) + ->actions([ + Action::make('revoke') + ->label('Revoke') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (PersonalAccessToken $record): bool => $this->ownsToken($record)) + ->action(function (PersonalAccessToken $record): void { + if (! $this->ownsToken($record)) { + return; + } + + app(SuperAdminAuditLogger::class)->record( + 'support-api-token.revoked', + $record, + ['fields' => ['name', 'abilities', 'expires_at']], + actor: $this->getUser(), + source: static::class + ); + + $record->delete(); + + Notification::make() + ->success() + ->title('Token revoked') + ->send(); + }), + ]) + ->emptyStateHeading('No support API tokens') + ->emptyStateDescription('Create a token for external support tooling.'); + } + + private function getTokenQuery(): Builder + { + $user = $this->getUser(); + + if (! $user) { + return PersonalAccessToken::query()->whereRaw('1 = 0'); + } + + return PersonalAccessToken::query() + ->where('tokenable_id', $user->getKey()) + ->where('tokenable_type', $user->getMorphClass()); + } + + private function getUser(): ?User + { + $user = Filament::auth()->user(); + + return $user instanceof User ? $user : null; + } + + private function formatAbilities(mixed $state): string + { + if (is_array($state)) { + return implode(', ', $state); + } + + if (is_string($state)) { + return $state; + } + + return ''; + } + + /** + * @return array + */ + private function defaultAbilities(): array + { + $abilities = config('support-api.token.default_abilities', []); + + if (! is_array($abilities)) { + return ['support-admin']; + } + + $abilities = array_values(array_filter($abilities, fn ($ability) => is_string($ability) && $ability !== '')); + + if (! in_array('support-admin', $abilities, true)) { + $abilities[] = 'support-admin'; + } + + return array_values(array_unique($abilities)); + } + + /** + * @return array + */ + private function abilityOptions(): array + { + $options = []; + + foreach ($this->defaultAbilities() as $ability) { + $options[$ability] = $ability; + } + + return $options; + } + + /** + * @param array $abilities + * @return array + */ + private function normalizeAbilities(array $abilities): array + { + $allowed = $this->defaultAbilities(); + $filtered = array_values(array_intersect($abilities, $allowed)); + + if (! in_array('support-admin', $filtered, true)) { + $filtered[] = 'support-admin'; + } + + sort($filtered); + + return $filtered; + } + + private function defaultTokenName(): string + { + $name = config('support-api.token.name'); + + if (is_string($name) && $name !== '') { + return $name; + } + + return 'support-api'; + } + + private function normalizeTokenName(?string $name): string + { + $name = $name ? trim($name) : ''; + + return $name !== '' ? $name : $this->defaultTokenName(); + } + + private function normalizeExpiresAt(mixed $expiresAt): ?Carbon + { + if ($expiresAt instanceof Carbon) { + return $expiresAt; + } + + if ($expiresAt instanceof \DateTimeInterface) { + return Carbon::instance($expiresAt); + } + + if (is_string($expiresAt) && $expiresAt !== '') { + return Carbon::parse($expiresAt); + } + + return null; + } + + private function recordTokenCreated(NewAccessToken $token, array $abilities, User $user): void + { + $actionLog = app(SuperAdminAuditLogger::class); + + $actionLog->record( + 'support-api-token.created', + $token->accessToken, + [ + 'fields' => ['name', 'abilities', 'expires_at'], + 'abilities' => $abilities, + ], + actor: $user, + source: static::class + ); + } + + private function ownsToken(PersonalAccessToken $token): bool + { + $user = $this->getUser(); + + if (! $user) { + return false; + } + + return (int) $token->tokenable_id === (int) $user->getKey() + && $token->tokenable_type === $user->getMorphClass(); + } +} diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index 743f492..6edb077 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -81,7 +81,7 @@ class SuperAdminPanelProvider extends PanelProvider /*->plugin( BlogPlugin::make() )*/ - ->profile() + ->profile(\App\Filament\SuperAdmin\Pages\Auth\EditProfile::class, isSimple: false) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->widgets([ Widgets\AccountWidget::class, diff --git a/tests/Feature/SuperAdminSupportApiTokensTest.php b/tests/Feature/SuperAdminSupportApiTokensTest.php new file mode 100644 index 0000000..bf2c10c --- /dev/null +++ b/tests/Feature/SuperAdminSupportApiTokensTest.php @@ -0,0 +1,66 @@ +create(['role' => 'super_admin']); + + $this->bootSuperAdminPanel($user); + + Livewire::test(SupportApiTokenManager::class) + ->callTableAction('create_support_token', null, [ + 'name' => 'support-api', + 'abilities' => ['support:read'], + 'expires_at' => now()->addDays(7)->toDateTimeString(), + ]); + + $token = PersonalAccessToken::query() + ->where('tokenable_id', $user->getKey()) + ->where('tokenable_type', $user->getMorphClass()) + ->where('name', 'support-api') + ->latest('id') + ->first(); + + $this->assertNotNull($token); + $this->assertContains('support-admin', $token->abilities ?? []); + $this->assertContains('support:read', $token->abilities ?? []); + $this->assertNotNull($token->expires_at); + } + + public function test_superadmin_can_revoke_support_api_token(): void + { + $user = User::factory()->create(['role' => 'super_admin']); + $token = $user->createToken('support-api', ['support:read'])->accessToken; + + $this->bootSuperAdminPanel($user); + + Livewire::test(SupportApiTokenManager::class) + ->callTableAction('revoke', $token); + + $this->assertFalse(PersonalAccessToken::query()->whereKey($token->getKey())->exists()); + } + + private function bootSuperAdminPanel(User $user): void + { + $panel = Filament::getPanel('superadmin'); + + $this->assertNotNull($panel); + + Filament::setCurrentPanel($panel); + Filament::bootCurrentPanel(); + Filament::auth()->login($user); + } +}