mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-11 02:50:29 +00:00
feat(settings): API token management UI for users
Adds a "API Tokens" settings page so an authenticated user can create and revoke Sanctum personal access tokens for the new authenticated write endpoints — using the official Sanctum API ($user->createToken() / tokens()). - New Volt component settings/api-tokens (create token, one-time plain-text reveal with copy-to-clipboard, list + revoke own tokens). - Registered route settings.api-tokens (country-prefixed, auth group) and added a nav entry in the settings layout. - SEO definition for the new page. - Pest feature tests (create/reveal-once, validation, revoke, ownership scoping) and a Pest browser screenshot test.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<flux:navlist.item :href="route('settings.profile', ['country' => str(session('lang_country', 'de'))->after('-')->lower()])" wire:navigate>{{ __('Profile') }}</flux:navlist.item>
|
||||
{{--<flux:navlist.item :href="route('settings.password')" wire:navigate>{{ __('Password') }}</flux:navlist.item>--}}
|
||||
<flux:navlist.item :href="route('settings.appearance', ['country' => str(session('lang_country', 'de'))->after('-')->lower()])" wire:navigate>{{ __('Appearance') }}</flux:navlist.item>
|
||||
<flux:navlist.item :href="route('settings.api-tokens', ['country' => str(session('lang_country', 'de'))->after('-')->lower()])" wire:navigate>{{ __('API Tokens') }}</flux:navlist.item>
|
||||
</flux:navlist>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
use App\Attributes\SeoDataAttribute;
|
||||
use App\Traits\SeoTrait;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
new
|
||||
#[SeoDataAttribute(key: 'settings_api_tokens')]
|
||||
class extends Component {
|
||||
use SeoTrait;
|
||||
|
||||
#[Validate('required|string|max:255')]
|
||||
public string $name = '';
|
||||
|
||||
/**
|
||||
* The plain-text token, shown to the user exactly once after creation.
|
||||
*/
|
||||
public ?string $plainTextToken = null;
|
||||
|
||||
/**
|
||||
* Create a new personal access token for the authenticated user.
|
||||
*/
|
||||
public function createToken(): void
|
||||
{
|
||||
$this->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(),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<section class="w-full">
|
||||
@include('partials.settings-heading')
|
||||
|
||||
<x-settings.layout :heading="__('API Tokens')"
|
||||
:subheading="__('Erstelle persönliche Zugriffstokens, um über die API auf dein Konto zuzugreifen.')">
|
||||
|
||||
<div class="space-y-8">
|
||||
<flux:text>
|
||||
{{ __('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']) }}
|
||||
</flux:text>
|
||||
|
||||
{{-- One-time token reveal --}}
|
||||
@if ($plainTextToken)
|
||||
<flux:callout variant="success" icon="key" x-data="{ copied: false }">
|
||||
<flux:callout.heading>{{ __('Dein neues API Token') }}</flux:callout.heading>
|
||||
<flux:callout.text>
|
||||
<p class="mb-3">
|
||||
{{ __('Kopiere dein Token jetzt. Aus Sicherheitsgründen wird es dir nur dieses eine Mal angezeigt.') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:input x-ref="token" readonly value="{{ $plainTextToken }}" class="font-mono" />
|
||||
<flux:button type="button" icon="clipboard-document"
|
||||
x-on:click="navigator.clipboard.writeText($refs.token.value); copied = true; setTimeout(() => copied = false, 2000)">
|
||||
<span x-show="!copied">{{ __('Kopieren') }}</span>
|
||||
<span x-show="copied" x-cloak>{{ __('Kopiert!') }}</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:callout.text>
|
||||
<x-slot name="actions">
|
||||
<flux:button variant="ghost" size="sm" wire:click="dismissPlainTextToken">
|
||||
{{ __('Verstanden') }}
|
||||
</flux:button>
|
||||
</x-slot>
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
{{-- Create token form --}}
|
||||
<form wire:submit="createToken" class="space-y-4">
|
||||
<flux:input wire:model="name"
|
||||
:label="__('Token-Name')"
|
||||
:placeholder="__('z. B. Externer Kurs-Sync')"
|
||||
:description="__('Ein aussagekräftiger Name hilft dir, das Token später wiederzuerkennen.')" />
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<flux:button variant="primary" type="submit" icon="plus">
|
||||
{{ __('Token erstellen') }}
|
||||
</flux:button>
|
||||
<x-action-message on="token-created">
|
||||
{{ __('Token erstellt.') }}
|
||||
</x-action-message>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
{{-- Existing tokens --}}
|
||||
<div>
|
||||
<flux:heading size="lg" class="mb-4">{{ __('Aktive Tokens') }}</flux:heading>
|
||||
|
||||
@if ($tokens->isEmpty())
|
||||
<flux:text class="text-zinc-500 dark:text-zinc-400">
|
||||
{{ __('Du hast noch keine API Tokens erstellt.') }}
|
||||
</flux:text>
|
||||
@else
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Name') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Zuletzt verwendet') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Erstellt') }}</flux:table.column>
|
||||
<flux:table.column />
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@foreach ($tokens as $token)
|
||||
<flux:table.row wire:key="token-{{ $token->id }}">
|
||||
<flux:table.cell variant="strong">{{ $token->name }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@if ($token->last_used_at)
|
||||
{{ $token->last_used_at->diffForHumans() }}
|
||||
@else
|
||||
<flux:badge size="sm" color="zinc">{{ __('Nie') }}</flux:badge>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>{{ $token->created_at->format('d.m.Y') }}</flux:table.cell>
|
||||
<flux:table.cell align="end">
|
||||
<flux:tooltip :content="__('Widerrufen')">
|
||||
<flux:button variant="danger" size="sm" icon="trash"
|
||||
:aria-label="__('Token widerrufen')"
|
||||
wire:click="deleteToken({{ $token->id }})"
|
||||
wire:confirm="{{ __('Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.', ['name' => $token->name]) }}" />
|
||||
</flux:tooltip>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-settings.layout>
|
||||
</section>
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
it('shows the api token management UI and the one-time token reveal', function () {
|
||||
$user = actingAsUser(['name' => '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');
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('mounts the api tokens page when authenticated', function () {
|
||||
actingAsUser();
|
||||
|
||||
Livewire::test('settings.api-tokens')->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();
|
||||
});
|
||||
Reference in New Issue
Block a user