diff --git a/app/Attributes/SeoDataAttribute.php b/app/Attributes/SeoDataAttribute.php index 4bfc1b5..a80dbb9 100644 --- a/app/Attributes/SeoDataAttribute.php +++ b/app/Attributes/SeoDataAttribute.php @@ -200,6 +200,14 @@ class SeoDataAttribute twitter_username: $domainTwitter, site_name: $domainSiteName, ), + 'settings_api_tokens' => new SEOData( + title: __('API Tokens - Einstellungen'), + description: __('Verwalte deine persönlichen Zugriffstokens für den programmatischen API-Zugriff auf dein Bitcoin Meetup Konto.'), + author: $domainAuthor, + image: $domainImage, + twitter_username: $domainTwitter, + site_name: $domainSiteName, + ), 'settings_delete_user_form' => new SEOData( title: __('Konto löschen - Bitcoin Meetups'), description: __('Informationen zum Löschen deines Bitcoin Meetup Kontos.'), @@ -298,6 +306,7 @@ class SeoDataAttribute if (empty(self::$seoDefinitions)) { self::initDefinitions(); } + return self::$seoDefinitions[$key] ?? self::$seoDefinitions['default']; } @@ -307,6 +316,7 @@ class SeoDataAttribute if ($this->key) { return self::getData($this->key); } + return self::getData('default'); // Fallback } } diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php index 73c101a..cc673cf 100644 --- a/resources/views/components/settings/layout.blade.php +++ b/resources/views/components/settings/layout.blade.php @@ -4,6 +4,7 @@ {{ __('Profile') }} {{--{{ __('Password') }}--}} {{ __('Appearance') }} + {{ __('API Tokens') }} diff --git a/resources/views/livewire/settings/api-tokens.blade.php b/resources/views/livewire/settings/api-tokens.blade.php new file mode 100644 index 0000000..6a07cca --- /dev/null +++ b/resources/views/livewire/settings/api-tokens.blade.php @@ -0,0 +1,163 @@ +validate(); + + $this->plainTextToken = Auth::user() + ->createToken($this->name) + ->plainTextToken; + + $this->reset('name'); + + $this->dispatch('token-created'); + } + + /** + * Revoke (delete) one of the authenticated user's tokens. + */ + public function deleteToken(int $tokenId): void + { + Auth::user()->tokens()->whereKey($tokenId)->delete(); + + $this->dispatch('token-deleted'); + } + + /** + * Dismiss the one-time plain-text token display. + */ + public function dismissPlainTextToken(): void + { + $this->plainTextToken = null; + } + + public function with(): array + { + return [ + 'tokens' => Auth::user()->tokens()->latest()->get(), + ]; + } +}; ?> + +
+ @include('partials.settings-heading') + + + +
+ + {{ __('Mit einem persönlichen Zugriffstoken kannst du deine Kurse und Kurs-Events programmatisch über die API verwalten (z. B. zum Synchronisieren aus einem externen System). Sende das Token als Bearer-Token im :header-Header.', ['header' => 'Authorization']) }} + + + {{-- One-time token reveal --}} + @if ($plainTextToken) + + {{ __('Dein neues API Token') }} + +

+ {{ __('Kopiere dein Token jetzt. Aus Sicherheitsgründen wird es dir nur dieses eine Mal angezeigt.') }} +

+
+ + + {{ __('Kopieren') }} + {{ __('Kopiert!') }} + +
+
+ + + {{ __('Verstanden') }} + + +
+ @endif + + {{-- Create token form --}} +
+ + +
+ + {{ __('Token erstellen') }} + + + {{ __('Token erstellt.') }} + +
+ + + + + {{-- Existing tokens --}} +
+ {{ __('Aktive Tokens') }} + + @if ($tokens->isEmpty()) + + {{ __('Du hast noch keine API Tokens erstellt.') }} + + @else + + + {{ __('Name') }} + {{ __('Zuletzt verwendet') }} + {{ __('Erstellt') }} + + + + @foreach ($tokens as $token) + + {{ $token->name }} + + @if ($token->last_used_at) + {{ $token->last_used_at->diffForHumans() }} + @else + {{ __('Nie') }} + @endif + + {{ $token->created_at->format('d.m.Y') }} + + + + + + + @endforeach + + + @endif +
+
+
+
diff --git a/routes/web.php b/routes/web.php index e1909bd..9aa1704 100644 --- a/routes/web.php +++ b/routes/web.php @@ -165,6 +165,7 @@ Route::middleware(['auth']) Route::livewire('/settings/profile', 'settings.profile')->name('settings.profile'); Route::livewire('/settings/password', 'settings.password')->name('settings.password'); Route::livewire('/settings/appearance', 'settings.appearance')->name('settings.appearance'); + Route::livewire('/settings/api-tokens', 'settings.api-tokens')->name('settings.api-tokens'); }); // Commented out feed routes (RSS/Atom feeds) diff --git a/tests/Browser/Settings/ApiTokensScreenshotTest.php b/tests/Browser/Settings/ApiTokensScreenshotTest.php new file mode 100644 index 0000000..6835308 --- /dev/null +++ b/tests/Browser/Settings/ApiTokensScreenshotTest.php @@ -0,0 +1,19 @@ + 'Lecturer Demo', 'is_lecturer' => true]); + + // Pre-existing token so the "Aktive Tokens" table is populated. + $user->createToken('Mein Laptop'); + + $page = visit('/de/settings/api-tokens'); + + $page->assertSee('API Tokens') + ->fill('name', 'Externer Kurs-Sync') + ->click('Token erstellen') + ->wait(1) + ->assertSee('Dein neues API Token') + ->assertSee('Aktive Tokens') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'settings-api-tokens'); +}); diff --git a/tests/Feature/Settings/ApiTokensTest.php b/tests/Feature/Settings/ApiTokensTest.php new file mode 100644 index 0000000..e4ea5e5 --- /dev/null +++ b/tests/Feature/Settings/ApiTokensTest.php @@ -0,0 +1,66 @@ +assertStatus(200); +}); + +it('creates a personal access token and reveals it once', function () { + $user = actingAsUser(); + + Livewire::test('settings.api-tokens') + ->set('name', 'Externer Kurs-Sync') + ->call('createToken') + ->assertHasNoErrors() + ->assertDispatched('token-created') + ->assertSet('name', '') + ->assertSet('plainTextToken', fn ($token) => is_string($token) && str_contains($token, '|')); + + expect($user->tokens()->where('name', 'Externer Kurs-Sync')->exists())->toBeTrue(); +}); + +it('requires a token name', function () { + actingAsUser(); + + Livewire::test('settings.api-tokens') + ->set('name', '') + ->call('createToken') + ->assertHasErrors(['name' => 'required']); +}); + +it('revokes a token', function () { + $user = actingAsUser(); + $token = $user->createToken('to-be-revoked')->accessToken; + + Livewire::test('settings.api-tokens') + ->call('deleteToken', $token->id) + ->assertDispatched('token-deleted'); + + expect($user->tokens()->whereKey($token->id)->exists())->toBeFalse(); +}); + +it('only lists the authenticated user\'s own tokens', function () { + $user = actingAsUser(); + $user->createToken('mine'); + + $other = User::factory()->create(); + $other->createToken('theirs'); + + Livewire::test('settings.api-tokens') + ->assertViewHas('tokens', fn ($tokens) => $tokens->count() === 1 && $tokens->first()->name === 'mine'); +}); + +it('cannot revoke a token belonging to another user', function () { + actingAsUser(); + $other = User::factory()->create(); + $foreignToken = $other->createToken('theirs')->accessToken; + + Livewire::test('settings.api-tokens') + ->call('deleteToken', $foreignToken->id); + + expect($other->tokens()->whereKey($foreignToken->id)->exists())->toBeTrue(); +});