diff --git a/.github/media/settings-api-tokens.png b/.github/media/settings-api-tokens.png new file mode 100644 index 0000000..2f092da Binary files /dev/null and b/.github/media/settings-api-tokens.png differ 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/app/Http/Controllers/Api/CourseController.php b/app/Http/Controllers/Api/CourseController.php index 3e0e081..6e43e23 100644 --- a/app/Http/Controllers/Api/CourseController.php +++ b/app/Http/Controllers/Api/CourseController.php @@ -6,7 +6,9 @@ use App\Http\Controllers\Controller; use App\Models\Course; use App\Models\Lecturer; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; class CourseController extends Controller { @@ -16,36 +18,50 @@ class CourseController extends Controller public function index(Request $request) { return Course::query() - ->select('id', 'name', ) - ->orderBy('name') - ->when($request->has('user_id'), - fn(Builder $query) => $query->where('created_by', $request->user_id)) - ->when( + ->select('id', 'name') + ->orderBy('name') + ->when($request->has('user_id'), + fn (Builder $query) => $query->where('created_by', $request->user_id)) + ->when( $request->search, fn (Builder $query) => $query ->where('name', 'ilike', "%{$request->search}%") ) - ->when( - $request->exists('selected'), - fn (Builder $query) => $query->whereIn('id', - $request->input('selected', [])), - fn (Builder $query) => $query->limit(10) - ) - ->get() - ->map(function (Course $course) { - $course->image = $course->getFirstMediaUrl('logo', - 'thumb'); + ->when( + $request->exists('selected'), + fn (Builder $query) => $query->whereIn('id', + $request->input('selected', [])), + fn (Builder $query) => $query->limit(10) + ) + ->get() + ->map(function (Course $course) { + $course->image = $course->getFirstMediaUrl('logo', + 'thumb'); - return $course; - }); + return $course; + }); } /** * Store a newly created resource in storage. + * + * Allows an authenticated lecturer to create a course programmatically + * (e.g. to sync courses from an external system). Validation mirrors the + * Livewire course create form; `created_by` is set by the model's creating hook. */ - public function store(Request $request) + public function store(Request $request): JsonResponse { - // + abort_unless((bool) $request->user()->is_lecturer, Response::HTTP_FORBIDDEN); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'lecturer_id' => ['required', 'exists:lecturers,id'], + 'description' => ['nullable', 'string'], + ]); + + $course = Course::create($validated); + + return response()->json($course->fresh(), Response::HTTP_CREATED); } /** @@ -58,10 +74,25 @@ class CourseController extends Controller /** * Update the specified resource in storage. + * + * Authorized for the course owner (or a super-admin). */ - public function update(Request $request, Course $course) + public function update(Request $request, Course $course): JsonResponse { - // + abort_unless( + (int) $course->created_by === $request->user()->id || $request->user()->hasRole('super-admin'), + Response::HTTP_FORBIDDEN + ); + + $validated = $request->validate([ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'lecturer_id' => ['sometimes', 'required', 'exists:lecturers,id'], + 'description' => ['sometimes', 'nullable', 'string'], + ]); + + $course->update($validated); + + return response()->json($course->fresh()); } /** diff --git a/app/Http/Controllers/Api/CourseEventController.php b/app/Http/Controllers/Api/CourseEventController.php new file mode 100644 index 0000000..7e8e038 --- /dev/null +++ b/app/Http/Controllers/Api/CourseEventController.php @@ -0,0 +1,84 @@ + + */ + public function index(Request $request): Collection + { + return CourseEvent::query() + ->with(['course:id,name', 'venue:id,name']) + ->where('created_by', $request->user()->id) + ->when( + $request->filled('course_id'), + fn (Builder $query) => $query->where('course_id', $request->integer('course_id')) + ) + ->orderByDesc('from') + ->get(); + } + + /** + * Store a newly created course event in storage. + * + * Allows an authenticated lecturer to create a dated course event + * programmatically. Validation mirrors the Livewire course event form; + * `created_by` is set by the model's creating hook. + */ + public function store(Request $request): JsonResponse + { + abort_unless((bool) $request->user()->is_lecturer, Response::HTTP_FORBIDDEN); + + $validated = $request->validate([ + 'course_id' => ['required', 'integer', 'exists:courses,id'], + 'venue_id' => ['required', 'integer', 'exists:venues,id'], + 'from' => ['required', 'date'], + 'to' => ['required', 'date', 'after_or_equal:from'], + 'link' => ['required', 'url', 'max:255'], + ]); + + $courseEvent = CourseEvent::create($validated); + + return response()->json($courseEvent->fresh(), Response::HTTP_CREATED); + } + + /** + * Update the specified course event in storage. + * + * Authorized for the course event owner (or a super-admin). + */ + public function update(Request $request, CourseEvent $courseEvent): JsonResponse + { + abort_unless( + (int) $courseEvent->created_by === $request->user()->id || $request->user()->hasRole('super-admin'), + Response::HTTP_FORBIDDEN + ); + + $validated = $request->validate([ + 'course_id' => ['sometimes', 'required', 'integer', 'exists:courses,id'], + 'venue_id' => ['sometimes', 'required', 'integer', 'exists:venues,id'], + 'from' => ['sometimes', 'required', 'date'], + 'to' => ['sometimes', 'required', 'date', 'after_or_equal:from'], + 'link' => ['sometimes', 'required', 'url', 'max:255'], + ]); + + $courseEvent->update($validated); + + return response()->json($courseEvent->fresh()); + } +} 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/api.php b/routes/api.php index 315ce4b..0b944c9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,11 +3,17 @@ use App\Http\Controllers\Api\CityController; use App\Http\Controllers\Api\CountryController; use App\Http\Controllers\Api\CourseController; +use App\Http\Controllers\Api\CourseEventController; use App\Http\Controllers\Api\HighscoreController; use App\Http\Controllers\Api\LecturerController; use App\Http\Controllers\Api\MeetupController; use App\Http\Controllers\Api\VenueController; +use App\Http\Controllers\LnurlAuthController; +use App\Models\LibraryItem; +use App\Models\Meetup; +use App\Models\MeetupEvent; use App\Models\User; +use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -18,7 +24,8 @@ Route::middleware(['throttle:60,1']) Route::get('meetup/ical', [MeetupController::class, 'ical'])->name('api.meetup.ical'); Route::resource('meetup', MeetupController::class); Route::resource('lecturers', LecturerController::class); - Route::resource('courses', CourseController::class); + Route::resource('courses', CourseController::class) + ->only(['index', 'show']); Route::resource('cities', CityController::class); Route::resource('venues', VenueController::class); Route::get('highscores', [HighscoreController::class, 'index'])->name('highscores.index'); @@ -46,7 +53,7 @@ Route::middleware(['throttle:60,1']) ->pluck('nostr'); }); Route::get('bindles', function () { - return \App\Models\LibraryItem::query() + return LibraryItem::query() ->where('type', 'bindle') ->with([ 'media', @@ -61,7 +68,7 @@ Route::middleware(['throttle:60,1']) ]); }); Route::get('meetups', function (Request $request) { - return \App\Models\Meetup::query() + return Meetup::query() ->where('visible_on_map', true) ->with([ 'meetupEvents', @@ -95,9 +102,9 @@ Route::middleware(['throttle:60,1']) }); Route::get('meetup-events/{date?}', function ($date = null) { if ($date) { - $date = \Carbon\Carbon::parse($date); + $date = Carbon::parse($date); } - $events = \App\Models\MeetupEvent::query() + $events = MeetupEvent::query() ->with([ 'meetup.city.country', 'meetup.media', @@ -139,7 +146,7 @@ Route::middleware(['throttle:60,1']) }); Route::get('btc-map-communities', function () { return response()->json( - \App\Models\Meetup::query() + Meetup::query() ->with([ 'media', 'city.country', @@ -184,8 +191,29 @@ Route::middleware(['throttle:60,1']) }); }); -Route::get('/lnurl-auth-callback', [\App\Http\Controllers\LnurlAuthController::class, 'callback']) +/* + * Authenticated write endpoints (Sanctum token auth). + * Lets a lecturer create/update their own courses and course events + * programmatically, e.g. to sync events from an external system. + */ +Route::middleware('auth:sanctum') + ->as('api.') + ->group(function () { + Route::post('courses', [CourseController::class, 'store']) + ->name('courses.store'); + Route::patch('courses/{course}', [CourseController::class, 'update']) + ->name('courses.update'); + + Route::get('course-events', [CourseEventController::class, 'index']) + ->name('course-events.index'); + Route::post('course-events', [CourseEventController::class, 'store']) + ->name('course-events.store'); + Route::patch('course-events/{courseEvent}', [CourseEventController::class, 'update']) + ->name('course-events.update'); + }); + +Route::get('/lnurl-auth-callback', [LnurlAuthController::class, 'callback']) ->name('auth.ln.callback'); -Route::post('/check-auth-error', [\App\Http\Controllers\LnurlAuthController::class, 'checkError']) +Route::post('/check-auth-error', [LnurlAuthController::class, 'checkError']) ->name('auth.check-error'); 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/Api/CourseEventApiTest.php b/tests/Feature/Api/CourseEventApiTest.php new file mode 100644 index 0000000..c50aa3c --- /dev/null +++ b/tests/Feature/Api/CourseEventApiTest.php @@ -0,0 +1,126 @@ +create(); + + $this->postJson('/api/courses', [ + 'name' => 'Specter Shield Lite Workshop', + 'lecturer_id' => $lecturer->id, + ])->assertUnauthorized(); +}); + +it('forbids a non-lecturer from creating a course', function () { + Sanctum::actingAs(User::factory()->create(['is_lecturer' => false])); + $lecturer = Lecturer::factory()->create(); + + $this->postJson('/api/courses', [ + 'name' => 'Specter Shield Lite Workshop', + 'lecturer_id' => $lecturer->id, + ])->assertForbidden(); +}); + +it('lets a lecturer create a course', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + $lecturer = Lecturer::factory()->create(); + + $this->postJson('/api/courses', [ + 'name' => 'Specter Shield Lite Workshop', + 'lecturer_id' => $lecturer->id, + 'description' => 'Hardware-Wallet selbst bauen.', + ]) + ->assertCreated() + ->assertJsonPath('name', 'Specter Shield Lite Workshop'); + + $this->assertDatabaseHas('courses', [ + 'name' => 'Specter Shield Lite Workshop', + 'created_by' => $user->id, + ]); +}); + +it('lets a lecturer create a course event', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + $course = Course::factory()->create(); + $venue = Venue::factory()->create(); + + $this->postJson('/api/course-events', [ + 'course_id' => $course->id, + 'venue_id' => $venue->id, + 'from' => '2026-07-01 18:00:00', + 'to' => '2026-07-01 21:00:00', + 'link' => 'https://clavastack.com/produkt/specter-shield-lite-workshop', + ]) + ->assertCreated() + ->assertJsonPath('course_id', $course->id); + + $this->assertDatabaseHas('course_events', [ + 'course_id' => $course->id, + 'venue_id' => $venue->id, + 'created_by' => $user->id, + ]); +}); + +it('fails course event validation without required fields', function () { + Sanctum::actingAs(User::factory()->lecturer()->create()); + + $this->postJson('/api/course-events', []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['course_id', 'venue_id', 'from', 'to', 'link']); +}); + +it('returns only the authenticated user\'s own course events', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + $other = User::factory()->lecturer()->create(); + + CourseEvent::factory()->count(2)->create(['created_by' => $user->id]); + CourseEvent::factory()->create(['created_by' => $other->id]); + + $response = $this->getJson('/api/course-events'); + + $response->assertSuccessful(); + expect($response->json())->toHaveCount(2); + collect($response->json())->each( + fn ($event) => expect($event['created_by'])->toBe($user->id) + ); +}); + +it('filters own course events by course_id', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + + $event = CourseEvent::factory()->create(['created_by' => $user->id]); + CourseEvent::factory()->create(['created_by' => $user->id]); + + $response = $this->getJson('/api/course-events?course_id='.$event->course_id); + + $response->assertSuccessful(); + expect($response->json())->toHaveCount(1) + ->and($response->json('0.id'))->toBe($event->id); +}); + +it('lets the owner update their course event', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + $event = CourseEvent::factory()->create(['created_by' => $user->id]); + + $this->patchJson('/api/course-events/'.$event->id, [ + 'link' => 'https://einundzwanzig.space/courses/updated', + ]) + ->assertSuccessful() + ->assertJsonPath('link', 'https://einundzwanzig.space/courses/updated'); +}); + +it('forbids updating a course event owned by someone else', function () { + $owner = User::factory()->lecturer()->create(); + $event = CourseEvent::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->lecturer()->create()); + + $this->patchJson('/api/course-events/'.$event->id, [ + 'link' => 'https://einundzwanzig.space/courses/hijacked', + ])->assertForbidden(); +}); 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(); +});