-
-
-
-
-
- {{ __('Settings') }}
-
-
-
-
-
-
-
@endauth
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php
index 8a3ab09..697fae9 100644
--- a/resources/views/livewire/auth/login.blade.php
+++ b/resources/views/livewire/auth/login.blade.php
@@ -282,42 +282,35 @@ class extends Component {
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
}
- public function checkAuth()
+ public function checkAuth(): void
{
$loginKey = LoginKey::query()
->where('k1', $this->k1)
- ->whereDate('created_at', '>=', now()->subMinutes(5))
+ ->where('created_at', '>=', now()->subMinutes(5))
->first();
- if ($loginKey) {
- // Persist the locale choice before the auth round-trip — once we
- // redirect, this component is unmounted and $currentLangCountry
- // would otherwise be lost.
- session(['lang_country' => $this->currentLangCountry]);
-
- // Hand off to a dedicated controller via full-page redirect.
- // Calling auth()->login() inside the wire:poll handler rotates
- // the session id and CSRF token mid-flight. Any Livewire request
- // that arrives in the same window — a parallel wire:poll tick,
- // a sibling component update, a click on the Nostr button —
- // would then 419 (TokenMismatch). The controller performs the
- // login on a clean, non-Livewire request before redirecting on
- // to the dashboard.
- return $this->redirect(
- route('auth.ln.complete', ['k1' => $this->k1]),
- navigate: false,
- );
+ if (! $loginKey) {
+ return;
}
- // Check if k1 has expired (older than 5 minutes)
- $k1CreatedAt = now()->subMinutes(5);
- if ($this->k1 && now()->diffInMinutes($k1CreatedAt) >= 5) {
- $this->authError = 'Session expired. Please try again.';
+ // Persist the locale choice before the auth round-trip — once we
+ // navigate, this component is unmounted and $currentLangCountry
+ // would otherwise be lost.
+ session(['lang_country' => $this->currentLangCountry]);
- return true;
- }
-
- return true;
+ // Hand the full-page navigation off to the client: returning a
+ // Livewire redirect from inside wire:poll has shown races with
+ // subsequent poll ticks (visible as a "request loop without
+ // redirect" for the user). Dispatching an event lets Alpine pause
+ // wire:poll via lightningLoginInProgress and run a clean
+ // window.location navigation. The dedicated /auth/complete-lightning
+ // controller then performs auth()->login() on a non-Livewire
+ // request, avoiding the session-id/CSRF rotation race that
+ // previously yielded 419s.
+ $this->dispatch(
+ 'lightning-login-ready',
+ url: route('auth.ln.complete', ['k1' => $this->k1]),
+ );
}
/**
@@ -345,7 +338,8 @@ class extends Component {
@@ -447,7 +441,12 @@ class extends Component {
flight. Otherwise wire:poll can fire a parallel /livewire/update
request that races with auth()->login()'s session migration and
lands on an invalidated session id, producing 419 TokenMismatch. --}}
-
+ {{-- Pause Livewire polling while either login flow is mid-flight.
+ Otherwise wire:poll can fire a parallel /livewire/update request
+ that races with the navigation handled in nostrLogin.js for Nostr
+ or the controller-driven Lightning flow, yielding the "request loop
+ without redirect" symptom seen in production. --}}
+
diff --git a/resources/views/livewire/meetups/create.blade.php b/resources/views/livewire/meetups/create.blade.php
index 3822016..61af2f4 100644
--- a/resources/views/livewire/meetups/create.blade.php
+++ b/resources/views/livewire/meetups/create.blade.php
@@ -83,7 +83,12 @@ class extends Component {
'visible_on_map' => ['boolean'],
]);
- $meetup = Meetup::create($validated);
+ $meetup = Meetup::create($validated + ['created_by' => auth()->id()]);
+
+ // Attach the creator to meetup_user so they appear under "My-Meetups"
+ // and pass the new edit-permission check (which is based on this pivot,
+ // not on created_by).
+ $meetup->users()->attach(auth()->id());
if ($this->logo) {
$meetup
diff --git a/resources/views/livewire/meetups/edit.blade.php b/resources/views/livewire/meetups/edit.blade.php
index 14134c9..71d7025 100644
--- a/resources/views/livewire/meetups/edit.blade.php
+++ b/resources/views/livewire/meetups/edit.blade.php
@@ -84,13 +84,23 @@ class extends Component {
}
/**
- * Enforce that only the meetup's creator may load or update this view.
- * Mirrors services/edit and lecturer-edit. Removing this guard reopens
- * the IDOR closed by 90835f8 (security: critical fixes / edit authz).
+ * Enforce that only users who have added the meetup to their personal
+ * "My-Meetups" list (the meetup_user pivot) may load or update this view.
+ * Editing is intentionally not restricted to the original `created_by`
+ * — any member of the meetup's user list is treated as an editor.
*/
protected function authorizeAccess(): void
{
- if (! is_null($this->meetup->created_by) && auth()->id() !== $this->meetup->created_by) {
+ if (! auth()->check()) {
+ abort(403);
+ }
+
+ $isMember = $this->meetup
+ ->users()
+ ->whereKey(auth()->id())
+ ->exists();
+
+ if (! $isMember) {
abort(403);
}
}
diff --git a/tests/Browser/SidebarNavigationTest.php b/tests/Browser/SidebarNavigationTest.php
new file mode 100644
index 0000000..7054576
--- /dev/null
+++ b/tests/Browser/SidebarNavigationTest.php
@@ -0,0 +1,32 @@
+create(['code' => 'de']);
+ City::factory()->create(['country_id' => $country->id]);
+});
+
+it('renders the sidebar with the user profile reachable on mobile viewport', function () {
+ actingAsUser(['name' => 'Sidebar Tester']);
+
+ $page = visit('/de/dashboard');
+
+ $page->resize(390, 844)
+ ->click('[aria-label="Menü öffnen"]')
+ ->assertSee('Dashboard')
+ ->assertSee('Repository')
+ ->assertSee('Sidebar Tester');
+});
+
+it('renders the sidebar with the user profile on desktop viewport', function () {
+ actingAsUser(['name' => 'Sidebar Tester']);
+
+ $page = visit('/de/dashboard');
+
+ $page->resize(1280, 800)
+ ->assertSee('Dashboard')
+ ->assertSee('Repository')
+ ->assertSee('Sidebar Tester');
+});
diff --git a/tests/Feature/Auth/LnurlAuthTest.php b/tests/Feature/Auth/LnurlAuthTest.php
index 5e9ce7f..06be8f7 100644
--- a/tests/Feature/Auth/LnurlAuthTest.php
+++ b/tests/Feature/Auth/LnurlAuthTest.php
@@ -105,7 +105,7 @@ it('returns 404 when the k1 path parameter is malformed', function () {
->assertNotFound();
});
-it('redirects auth.login checkAuth() to the completion URL without rotating the session', function () {
+it('dispatches lightning-login-ready from auth.login checkAuth() without rotating the session', function () {
$user = User::factory()->create();
$k1 = bin2hex(random_bytes(32));
LoginKey::factory()->create([
@@ -117,10 +117,41 @@ it('redirects auth.login checkAuth() to the completion URL without rotating the
Livewire::test('auth.login')
->set('k1', $k1)
->call('checkAuth')
- ->assertRedirect(route('auth.ln.complete', ['k1' => $k1]));
+ ->assertDispatched('lightning-login-ready', url: route('auth.ln.complete', ['k1' => $k1]));
// The poll handler must NOT log the user in directly — that's the
// controller's job. Logging in here would rotate the session id and
// CSRF token mid-poll, producing 419s on any in-flight Livewire request.
+ // It also must NOT return a server-side redirect: emitting an event lets
+ // Alpine pause wire:poll via lightningLoginInProgress before navigating,
+ // which avoids the "request loop without redirect" symptom in production.
+ expect(auth()->check())->toBeFalse();
+});
+
+it('does not dispatch lightning-login-ready when no LoginKey exists', function () {
+ $k1 = bin2hex(random_bytes(32));
+
+ Livewire::test('auth.login')
+ ->set('k1', $k1)
+ ->call('checkAuth')
+ ->assertNotDispatched('lightning-login-ready');
+
+ expect(auth()->check())->toBeFalse();
+});
+
+it('does not dispatch lightning-login-ready when the LoginKey is older than 5 minutes', function () {
+ $user = User::factory()->create();
+ $k1 = bin2hex(random_bytes(32));
+ LoginKey::factory()->create([
+ 'user_id' => $user->id,
+ 'k1' => $k1,
+ 'created_at' => now()->subMinutes(10),
+ ]);
+
+ Livewire::test('auth.login')
+ ->set('k1', $k1)
+ ->call('checkAuth')
+ ->assertNotDispatched('lightning-login-ready');
+
expect(auth()->check())->toBeFalse();
});
diff --git a/tests/Feature/Livewire/MeetupMountTest.php b/tests/Feature/Livewire/MeetupMountTest.php
index e1b9883..776d63c 100644
--- a/tests/Feature/Livewire/MeetupMountTest.php
+++ b/tests/Feature/Livewire/MeetupMountTest.php
@@ -4,6 +4,7 @@ use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use App\Models\MeetupEvent;
+use App\Models\User;
use Livewire\Livewire;
beforeEach(function () {
@@ -29,22 +30,42 @@ it('mounts meetups.create when authenticated', function () {
Livewire::test('meetups.create')->assertStatus(200);
});
-it('mounts meetups.edit when authenticated as the meetup creator', function () {
+it('mounts meetups.edit when the authenticated user has added the meetup to My-Meetups', function () {
$owner = actingAsUser();
- $meetup = Meetup::factory()->create([
- 'city_id' => $this->city->id,
- 'created_by' => $owner->id,
- ]);
+ $meetup = Meetup::factory()->create(['city_id' => $this->city->id]);
+ $meetup->users()->attach($owner);
Livewire::test('meetups.edit', ['meetup' => $meetup])->assertStatus(200);
});
-it('aborts meetups.edit with 403 when authenticated user is not the creator', function () {
+it('mounts meetups.edit for a My-Meetups member even if another user created the meetup', function () {
+ $creator = User::factory()->create();
+ $member = actingAsUser();
+ $meetup = Meetup::factory()->create([
+ 'city_id' => $this->city->id,
+ 'created_by' => $creator->id,
+ ]);
+ $meetup->users()->attach($member);
+
+ Livewire::test('meetups.edit', ['meetup' => $meetup])->assertStatus(200);
+});
+
+it('aborts meetups.edit with 403 when the authenticated user has not added the meetup to My-Meetups', function () {
actingAsUser();
Livewire::test('meetups.edit', ['meetup' => $this->meetup])->assertStatus(403);
});
+it('aborts meetups.edit with 403 when the authenticated user is only the creator but not in My-Meetups', function () {
+ $creator = actingAsUser();
+ $meetup = Meetup::factory()->create([
+ 'city_id' => $this->city->id,
+ 'created_by' => $creator->id,
+ ]);
+
+ Livewire::test('meetups.edit', ['meetup' => $meetup])->assertStatus(403);
+});
+
it('mounts meetups.create-edit-events for new event', function () {
actingAsUser();
Livewire::test('meetups.create-edit-events', ['meetup' => $this->meetup])->assertStatus(200);
diff --git a/tests/Feature/Meetups/EditMeetupTest.php b/tests/Feature/Meetups/EditMeetupTest.php
index f40ecc7..b299908 100644
--- a/tests/Feature/Meetups/EditMeetupTest.php
+++ b/tests/Feature/Meetups/EditMeetupTest.php
@@ -10,13 +10,13 @@ beforeEach(function () {
$this->city = City::factory()->create(['country_id' => $country->id]);
});
-it('updates an existing Meetup name when authenticated', function () {
- $owner = actingAsUser();
+it('updates an existing Meetup name when the user has it in My-Meetups', function () {
+ $member = actingAsUser();
$meetup = Meetup::factory()->create([
'city_id' => $this->city->id,
'name' => 'Original Name',
- 'created_by' => $owner->id,
]);
+ $meetup->users()->attach($member);
Livewire::test('meetups.edit', ['meetup' => $meetup])
->set('name', 'Updated Name')
@@ -29,12 +29,12 @@ it('updates an existing Meetup name when authenticated', function () {
});
it('rejects update when name collides with another existing Meetup', function () {
- $owner = actingAsUser();
+ $member = actingAsUser();
$meetup = Meetup::factory()->create([
'city_id' => $this->city->id,
'name' => 'Original Name',
- 'created_by' => $owner->id,
]);
+ $meetup->users()->attach($member);
Meetup::factory()->create(['name' => 'Other Name', 'city_id' => $this->city->id]);
Livewire::test('meetups.edit', ['meetup' => $meetup])
@@ -44,12 +44,12 @@ it('rejects update when name collides with another existing Meetup', function ()
});
it('allows update when name is unchanged (Rule::unique ignores own id)', function () {
- $owner = actingAsUser();
+ $member = actingAsUser();
$meetup = Meetup::factory()->create([
'city_id' => $this->city->id,
'name' => 'Original Name',
- 'created_by' => $owner->id,
]);
+ $meetup->users()->attach($member);
Livewire::test('meetups.edit', ['meetup' => $meetup])
->set('name', 'Original Name')
@@ -58,6 +58,19 @@ it('allows update when name is unchanged (Rule::unique ignores own id)', functio
->assertHasNoErrors();
});
+it('blocks updateMeetup when the user has not added the meetup to My-Meetups', function () {
+ actingAsUser();
+ $meetup = Meetup::factory()->create([
+ 'city_id' => $this->city->id,
+ 'name' => 'Original Name',
+ ]);
+
+ Livewire::test('meetups.edit', ['meetup' => $meetup])
+ ->assertStatus(403);
+
+ expect($meetup->refresh()->name)->toBe('Original Name');
+});
+
it('redirects guests when accessing meetup-edit', function () {
$meetup = Meetup::factory()->create(['city_id' => $this->city->id]);