feat(api): API-Token-Verwaltung & GET /events/{id}

Pro-User können in den Einstellungen (Tab „API") benannte Tokens erstellen
und widerrufen. Feature api_access im FeatureSeeder als Pro-Feature ergänzt.
Neuer GET /events/{id} Endpunkt im EventController.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-04-21 02:13:15 +02:00
parent 6b92544611
commit 2dd3903a4e
5 changed files with 148 additions and 1 deletions

View File

@ -67,6 +67,16 @@ class EventController extends Controller
], 201); ], 201);
} }
public function show(Request $request, string $id): JsonResponse
{
$event = $request->user()->events()->with('contacts')->findOrFail($id);
return response()->json([
'success' => true,
'data' => $event,
]);
}
public function update(Request $request, string $id): JsonResponse public function update(Request $request, string $id): JsonResponse
{ {
$event = $request->user()->events()->findOrFail($id); $event = $request->user()->events()->findOrFail($id);

View File

@ -44,6 +44,10 @@ class Index extends Component
public string $new_password = ''; public string $new_password = '';
public string $new_password_confirmation = ''; public string $new_password_confirmation = '';
// ── API-Token ─────────────────────────────────────────────────────────
public string $newTokenName = '';
public ?string $createdToken = null;
protected $listeners = [ protected $listeners = [
'smtp:test' => 'testSmtp', 'smtp:test' => 'testSmtp',
]; ];
@ -326,6 +330,31 @@ class Index extends Component
&& ($this->smtp_password || isset(auth()->user()->settings['smtp_password']))); && ($this->smtp_password || isset(auth()->user()->settings['smtp_password'])));
} }
public function getApiTokensProperty()
{
return auth()->user()->tokens()->latest()->get();
}
public function createApiToken(): void
{
$this->validate(['newTokenName' => 'required|string|max:80']);
$plain = \Illuminate\Support\Str::random(64);
auth()->user()->tokens()->create([
'token' => hash('sha256', $plain),
'name' => $this->newTokenName,
]);
$this->createdToken = $plain;
$this->newTokenName = '';
}
public function revokeApiToken(string $id): void
{
auth()->user()->tokens()->where('id', $id)->delete();
$this->createdToken = null;
}
public function render() public function render()
{ {
$affiliate = auth()->user()->affiliate; $affiliate = auth()->user()->affiliate;

View File

@ -45,6 +45,7 @@ class FeatureSeeder extends Seeder
['key' => 'ai_agent', 'label' => 'KI-Assistent', 'icon' => 'heroicon-o-sparkles', 'group' => $productivity, 'sort' => 3], ['key' => 'ai_agent', 'label' => 'KI-Assistent', 'icon' => 'heroicon-o-sparkles', 'group' => $productivity, 'sort' => 3],
['key' => 'calendar_sync', 'label' => 'Kalender-Synchronisierung', 'icon' => 'heroicon-o-arrow-path', 'group' => $integration, 'sort' => 1], ['key' => 'calendar_sync', 'label' => 'Kalender-Synchronisierung', 'icon' => 'heroicon-o-arrow-path', 'group' => $integration, 'sort' => 1],
['key' => 'automations', 'label' => 'Automationen', 'icon' => 'heroicon-o-bolt', 'group' => $integration, 'sort' => 2], ['key' => 'automations', 'label' => 'Automationen', 'icon' => 'heroicon-o-bolt', 'group' => $integration, 'sort' => 2],
['key' => 'api_access', 'label' => 'API-Zugang', 'icon' => 'heroicon-o-code-bracket', 'group' => $integration, 'sort' => 3],
['key' => 'speed', 'label' => 'GPT-4o Modell (schneller & präziser)', 'icon' => 'heroicon-o-bolt', 'group' => $performance, 'sort' => 1], ['key' => 'speed', 'label' => 'GPT-4o Modell (schneller & präziser)', 'icon' => 'heroicon-o-bolt', 'group' => $performance, 'sort' => 1],
]; ];
@ -69,7 +70,7 @@ class FeatureSeeder extends Seeder
$freeFeatures = ['calendar', 'reminders', 'tasks', 'notes', 'contacts', 'ai_agent']; $freeFeatures = ['calendar', 'reminders', 'tasks', 'notes', 'contacts', 'ai_agent'];
// Features die nur Pro bekommt // Features die nur Pro bekommt
$proFeatures = ['calendar_sync', 'automations', 'speed']; $proFeatures = ['calendar_sync', 'automations', 'api_access', 'speed'];
// Bestehende Verknüpfungen leeren // Bestehende Verknüpfungen leeren
DB::table('feature_plan')->delete(); DB::table('feature_plan')->delete();

View File

@ -20,6 +20,7 @@
'smtp' => ['label' => t('settings.tab.smtp'), 'icon' => 'envelope'], 'smtp' => ['label' => t('settings.tab.smtp'), 'icon' => 'envelope'],
'credits' => ['label' => t('settings.tab.credits'), 'icon' => 'bolt'], 'credits' => ['label' => t('settings.tab.credits'), 'icon' => 'bolt'],
...(!auth()->user()->isInternalUser() ? ['affiliate' => ['label' => 'Affiliate', 'icon' => 'gift']] : []), ...(!auth()->user()->isInternalUser() ? ['affiliate' => ['label' => 'Affiliate', 'icon' => 'gift']] : []),
'api' => ['label' => 'API', 'icon' => 'code-bracket'],
'account' => ['label' => t('settings.tab.account'), 'icon' => 'shield-exclamation'], 'account' => ['label' => t('settings.tab.account'), 'icon' => 'shield-exclamation'],
] as $key => $tab) ] as $key => $tab)
<button wire:click="$set('activeTab', '{{ $key }}')" <button wire:click="$set('activeTab', '{{ $key }}')"
@ -31,6 +32,7 @@
@elseif($tab['icon'] === 'envelope') <x-heroicon-o-envelope class="w-3.5 h-3.5"/> @elseif($tab['icon'] === 'envelope') <x-heroicon-o-envelope class="w-3.5 h-3.5"/>
@elseif($tab['icon'] === 'bolt') <x-heroicon-o-bolt class="w-3.5 h-3.5"/> @elseif($tab['icon'] === 'bolt') <x-heroicon-o-bolt class="w-3.5 h-3.5"/>
@elseif($tab['icon'] === 'gift') <x-heroicon-o-gift class="w-3.5 h-3.5"/> @elseif($tab['icon'] === 'gift') <x-heroicon-o-gift class="w-3.5 h-3.5"/>
@elseif($tab['icon'] === 'code-bracket') <x-heroicon-o-code-bracket class="w-3.5 h-3.5"/>
@elseif($tab['icon'] === 'shield-exclamation') <x-heroicon-o-shield-exclamation class="w-3.5 h-3.5"/> @elseif($tab['icon'] === 'shield-exclamation') <x-heroicon-o-shield-exclamation class="w-3.5 h-3.5"/>
@endif @endif
{{ $tab['label'] }} {{ $tab['label'] }}
@ -658,6 +660,110 @@
@endif @endif
{{-- ════════════════════════════════════════════════════════════ --}}
{{-- TAB: API --}}
{{-- ════════════════════════════════════════════════════════════ --}}
<div x-show="$wire.activeTab === 'api'" x-cloak>
<div class="bg-white border border-gray-100 rounded-xl shadow-sm">
<div class="flex items-center gap-3 px-6 py-5 border-b border-gray-100">
<div class="w-9 h-9 rounded-lg bg-indigo-50 flex items-center justify-center">
<x-heroicon-o-code-bracket class="w-4 h-4 text-indigo-500"/>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-800">API-Zugang</h3>
<p class="text-xs text-gray-500 mt-0.5">Erstelle persönliche Tokens für den Zugriff auf die Aziros-API.</p>
</div>
</div>
<div class="p-6 space-y-6">
{{-- Neuen Token erstellen --}}
@if(auth()->user()->hasFeature('api_access'))
<div>
<label class="{{ $labelClass }}">Neuen Token erstellen</label>
<div class="flex gap-2">
<input wire:model="newTokenName"
placeholder="z.B. Mein Skript, Zapier…"
class="{{ $inputClass }} flex-1"
wire:keydown.enter="createApiToken"/>
<button wire:click="createApiToken"
class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors shrink-0">
Erstellen
</button>
</div>
@error('newTokenName') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
</div>
@if($createdToken)
<div class="bg-green-50 border border-green-200 rounded-lg p-4 space-y-2">
<p class="text-xs font-medium text-green-800">Token wurde erstellt kopiere ihn jetzt, er wird nur einmal angezeigt.</p>
<div class="flex gap-2 items-center" x-data="{ copied: false }">
<code class="flex-1 text-xs bg-white border border-green-200 rounded px-3 py-2 text-gray-800 break-all font-mono select-all">{{ $createdToken }}</code>
<button @click="navigator.clipboard.writeText('{{ $createdToken }}'); copied = true; setTimeout(() => copied = false, 2000)"
class="px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 shrink-0">
<span x-show="!copied">Kopieren</span>
<span x-show="copied" x-cloak></span>
</button>
</div>
</div>
@endif
@else
<div class="flex items-center gap-3 p-4 bg-indigo-50 rounded-lg">
<x-heroicon-o-lock-closed class="w-5 h-5 text-indigo-400 shrink-0"/>
<div>
<p class="text-sm font-medium text-indigo-800">Pro-Feature</p>
<p class="text-xs text-indigo-600 mt-0.5">API-Tokens sind für Pro-Nutzer verfügbar.</p>
</div>
</div>
@endif
{{-- Bestehende Tokens --}}
@if($this->apiTokens->isNotEmpty())
<div>
<label class="{{ $labelClass }}">Aktive Tokens</label>
<div class="border border-gray-100 rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 text-left text-xs text-gray-500 uppercase tracking-wider">
<th class="px-4 py-2.5">Name</th>
<th class="px-4 py-2.5">Erstellt</th>
<th class="px-4 py-2.5">Zuletzt verwendet</th>
<th class="px-4 py-2.5"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@foreach($this->apiTokens as $token)
<tr class="hover:bg-gray-50/50">
<td class="px-4 py-3 font-medium text-gray-800">{{ $token->name ?? 'Unbenannt' }}</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ $token->created_at->format('d.m.Y') }}</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ $token->last_used_at?->diffForHumans() ?? '—' }}</td>
<td class="px-4 py-3 text-right">
<button wire:click="revokeApiToken('{{ $token->id }}')"
wire:confirm="Token widerrufen?"
class="text-xs text-red-500 hover:text-red-700 font-medium">
Widerrufen
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
{{-- API-Referenz --}}
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<p class="text-xs font-medium text-gray-700">Verwendung</p>
<code class="block text-xs text-gray-600 font-mono">Authorization: Bearer &lt;dein-token&gt;</code>
<code class="block text-xs text-gray-500 font-mono">GET https://api.aziros.com/v1/events?from=2026-01-01&amp;to=2026-12-31</code>
</div>
@endif
</div>
</div>
</div>
{{-- ════════════════════════════════════════════════════════════ --}} {{-- ════════════════════════════════════════════════════════════ --}}
{{-- TAB: GEFAHRENZONE --}} {{-- TAB: GEFAHRENZONE --}}
{{-- ════════════════════════════════════════════════════════════ --}} {{-- ════════════════════════════════════════════════════════════ --}}

View File

@ -57,6 +57,7 @@ Route::prefix('v1')->group(function () {
// Kalender // Kalender
Route::get('/events', [EventController::class, 'index']); Route::get('/events', [EventController::class, 'index']);
Route::post('/events', [EventController::class, 'store']); Route::post('/events', [EventController::class, 'store']);
Route::get('/events/{id}', [EventController::class, 'show']);
Route::put('/events/{id}', [EventController::class, 'update']); Route::put('/events/{id}', [EventController::class, 'update']);
Route::delete('/events/{id}', [EventController::class, 'destroy']); Route::delete('/events/{id}', [EventController::class, 'destroy']);