From 93e7fa7f345a9f74164bc9048f885d48cb2920bd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:42:01 +0200 Subject: [PATCH] feat(dashboard): show pending team invitations Login no longer accepts invitations, so the dashboard lists the unexpired invitations for the user's email with a link to the invitation page, where the user accepts them. --- app/Livewire/Dashboard.php | 31 +++++- resources/views/livewire/dashboard.blade.php | 17 +++ .../DashboardPendingInvitationsTest.php | 105 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/DashboardPendingInvitationsTest.php diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 31ac6cf8d7..2fc2553512 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -5,6 +5,7 @@ namespace App\Livewire; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; +use App\Models\TeamInvitation; use Illuminate\Support\Collection; use Livewire\Component; @@ -40,6 +41,34 @@ class Dashboard extends Component public function render() { - return view('livewire.dashboard'); + return view('livewire.dashboard', [ + 'pendingInvitations' => $this->pendingInvitations(), + ]); + } + + /** + * Unexpired invitations for the user's email to teams the user has not joined yet. + * + * Uses hasExpired() instead of isValid() so rendering never deletes invitations or users. + * + * @return Collection + */ + private function pendingInvitations(): Collection + { + $user = auth()->user(); + + if (! $user) { + return collect(); + } + + return TeamInvitation::query() + ->where('email', strtolower($user->email)) + ->whereHas('team') + ->whereNotIn('team_id', $user->teams()->select('teams.id')) + ->with('team:id,name') + ->oldest() + ->get() + ->reject(fn (TeamInvitation $invitation) => $invitation->hasExpired()) + ->values(); } } diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 4f887d7180..472b3e9961 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -15,6 +15,23 @@ @endphp
+ @if ($pendingInvitations->isNotEmpty()) +
+ @foreach ($pendingInvitations as $invitation) + +
+ Team {{ $invitation->team->name }} + invited you as {{ ucfirst($invitation->role) }}. + + Review invitation + +
+
+ @endforeach +
+ @endif + @if ($hasTrafficAnalytics) diff --git a/tests/Feature/DashboardPendingInvitationsTest.php b/tests/Feature/DashboardPendingInvitationsTest.php new file mode 100644 index 0000000000..21850794ee --- /dev/null +++ b/tests/Feature/DashboardPendingInvitationsTest.php @@ -0,0 +1,105 @@ +uuid(); + + return TeamInvitation::create(array_merge([ + 'team_id' => $team->id, + 'uuid' => $uuid, + 'email' => $email, + 'role' => 'admin', + 'link' => url('/invitations/'.$uuid), + 'via' => 'link', + ], $attributes)); +} + +beforeEach(function () { + Queue::fake(); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + + $this->user = User::factory()->create(['email' => 'invited@example.com']); + $this->ownTeam = Team::factory()->create(); + $this->user->teams()->attach($this->ownTeam, ['role' => 'owner']); + $this->invitingTeam = Team::factory()->create(['name' => 'Acme Platform']); + + Team::query()->update(['show_boarding' => false]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->ownTeam]); +}); + +it('shows a pending invitation for the user email with a review link', function () { + $invitation = createDashboardTestInvitation($this->invitingTeam, 'Invited@Example.com'); + + Livewire::test(Dashboard::class) + ->assertSee('Acme Platform') + ->assertSee('invited you as Admin.') + ->assertSee('Review invitation') + ->assertSee(route('team.invitation.show', $invitation->uuid), false); +}); + +it('does not show invitations for another email', function () { + $invitation = createDashboardTestInvitation($this->invitingTeam, 'someone-else@example.com'); + + Livewire::test(Dashboard::class) + ->assertDontSee('Acme Platform') + ->assertDontSee('Review invitation') + ->assertDontSee(route('team.invitation.show', $invitation->uuid), false); +}); + +it('does not show expired invitations and keeps them in place', function () { + $invitation = createDashboardTestInvitation($this->invitingTeam, 'invited@example.com'); + $invitation->forceFill([ + 'created_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1), + ])->save(); + + Livewire::test(Dashboard::class) + ->assertDontSee('Acme Platform') + ->assertDontSee('Review invitation'); + + expect(TeamInvitation::query()->whereKey($invitation->id)->exists())->toBeTrue() + ->and(User::query()->whereKey($this->user->id)->exists())->toBeTrue(); +}); + +it('does not show invitations to a team the user already belongs to', function () { + $this->user->teams()->attach($this->invitingTeam, ['role' => 'member']); + createDashboardTestInvitation($this->invitingTeam, 'invited@example.com'); + + Livewire::test(Dashboard::class) + ->assertDontSee('Acme Platform') + ->assertDontSee('Review invitation'); +}); + +it('no longer shows the invitation after it is accepted', function () { + $invitation = createDashboardTestInvitation($this->invitingTeam, 'invited@example.com'); + + $this->get(route('dashboard')) + ->assertOk() + ->assertSee(route('team.invitation.show', $invitation->uuid), false); + + $this->post(route('team.invitation.accept', $invitation->uuid)) + ->assertRedirect(route('team.index')); + + expect($this->user->teams()->whereKey($this->invitingTeam->id)->exists())->toBeTrue(); + + $this->actingAs($this->user->fresh()); + session(['currentTeam' => $this->invitingTeam->fresh()]); + + Livewire::test(Dashboard::class) + ->assertDontSee('Acme Platform') + ->assertDontSee('Review invitation') + ->assertDontSee(route('team.invitation.show', $invitation->uuid), false); +});