Add support API token management to profile
This commit is contained in:
@@ -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),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
280
app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php
Normal file
280
app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Widgets;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Sanctum\NewAccessToken;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class SupportApiTokenManager extends TableWidget
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->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<int, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
private function abilityOptions(): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach ($this->defaultAbilities() as $ability) {
|
||||
$options[$ability] = $ability;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $abilities
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
66
tests/Feature/SuperAdminSupportApiTokensTest.php
Normal file
66
tests/Feature/SuperAdminSupportApiTokensTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Filament\SuperAdmin\Widgets\SupportApiTokenManager;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SuperAdminSupportApiTokensTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_superadmin_can_create_support_api_token(): void
|
||||
{
|
||||
$user = User::factory()->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user