diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index 61efff39a9..08c913ee2e 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -70,21 +70,6 @@ class InviteLink extends Component if ($member_emails->contains($this->email)) { return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); } - $uuid = new_public_id(32); - $link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]); - $user = User::whereEmail($this->email)->first(); - - if (is_null($user)) { - $password = Str::password(); - $user = User::create([ - 'name' => str($this->email)->before('@'), - 'email' => $this->email, - 'password' => Hash::make($password), - 'force_password_reset' => true, - ]); - $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); - $link = $this->invitationUrl('auth.link', ['token' => $token]); - } $invitation = TeamInvitation::ownedByCurrentTeam()->whereEmail($this->email)->first(); if (! is_null($invitation)) { $invitationValid = $invitation->isValid(); @@ -95,6 +80,28 @@ class InviteLink extends Component } } + // Look up the user after the expired invitation is removed, because that can delete an unused account. + $uuid = new_public_id(32); + $link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]); + $user = User::whereEmail($this->email)->first(); + + // A user who never set a password cannot log in, so they get a magic link with a new temporary password. + if (is_null($user) || $user->isUnusedInvitedAccount()) { + $password = Str::password(); + if (is_null($user)) { + $user = User::create([ + 'name' => str($this->email)->before('@'), + 'email' => $this->email, + 'password' => Hash::make($password), + 'force_password_reset' => true, + ]); + } else { + $user->forceFill(['password' => Hash::make($password)])->save(); + } + $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); + $link = $this->invitationUrl('auth.link', ['token' => $token]); + } + $invitation = TeamInvitation::firstOrCreate([ 'team_id' => currentTeam()->id, 'uuid' => $uuid, diff --git a/app/Models/User.php b/app/Models/User.php index 223813c8f3..6e06123775 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -216,19 +216,21 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail */ public function deleteIfNotVerifiedAndForcePasswordReset(): void { - if ($this->hasVerifiedEmail() || $this->force_password_reset !== true) { - return; + if ($this->isUnusedInvitedAccount()) { + $this->delete(); } + } - if (TeamInvitation::whereEmail($this->email)->exists()) { - return; - } - - if ($this->belongsToNonPersonalTeam()) { - return; - } - - $this->delete(); + /** + * Whether an invitation created this account and nobody uses it: the owner never set a password, + * it has no pending invitations, and it belongs to no team other than its own personal team. + */ + public function isUnusedInvitedAccount(): bool + { + return ! $this->hasVerifiedEmail() + && $this->force_password_reset === true + && ! TeamInvitation::whereEmail($this->email)->exists() + && ! $this->belongsToNonPersonalTeam(); } /** diff --git a/app/Traits/DeletesUserSessions.php b/app/Traits/DeletesUserSessions.php index 44ff5f727d..32e2155052 100644 --- a/app/Traits/DeletesUserSessions.php +++ b/app/Traits/DeletesUserSessions.php @@ -3,20 +3,23 @@ namespace App\Traits; use App\Actions\User\RevokeUserTeamTokens; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Session; trait DeletesUserSessions { /** - * Delete all sessions for the current user. + * Delete all sessions for this user. * This will force the user to log in again on all devices. */ public function deleteAllSessions(): void { - // Invalidate the current session - Session::invalidate(); - Session::regenerateToken(); + // Invalidate the current session only when it belongs to this user, not to an admin who changes it + if (Auth::id() === $this->id) { + Session::invalidate(); + Session::regenerateToken(); + } DB::table('sessions')->where('user_id', $this->id)->delete(); RevokeUserTeamTokens::forUser($this->id); } diff --git a/tests/Feature/TeamInvitationHandlingTest.php b/tests/Feature/TeamInvitationHandlingTest.php index 36ee59235d..850528935f 100644 --- a/tests/Feature/TeamInvitationHandlingTest.php +++ b/tests/Feature/TeamInvitationHandlingTest.php @@ -7,6 +7,8 @@ use App\Models\Team; use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Once; use Livewire\Livewire; @@ -168,3 +170,83 @@ test('removes an unused pending user after the last revocation', function () { $this->assertDatabaseMissing('team_invitations', ['id' => $this->invitationA->id]); $this->assertDatabaseMissing('users', ['id' => $this->pendingUser->id]); }); + +function invitationMagicLinkPassword(TeamInvitation $invitation): ?string +{ + if (parse_url($invitation->link, PHP_URL_PATH) !== '/auth/link') { + return null; + } + parse_str((string) parse_url($invitation->link, PHP_URL_QUERY), $query); + + return explode('@@@', Crypt::decryptString($query['token']), 3)[2]; +} + +test('sends a working magic link when the same team invites again after expiration', function () { + $this->actingAs($this->ownerA); + session(['currentTeam' => $this->teamA]); + $this->invitationA->forceFill(['created_at' => now()->subDays(10)])->save(); + + Livewire::test(InviteLink::class) + ->set('email', $this->pendingUser->email) + ->call('viaLink') + ->assertDispatched('success'); + + $invitation = TeamInvitation::whereEmail($this->pendingUser->email)->sole(); + $user = User::whereEmail($this->pendingUser->email)->sole(); + $password = invitationMagicLinkPassword($invitation); + + expect($password)->not->toBeNull() + ->and(Hash::check($password, $user->password))->toBeTrue(); +}); + +test('sends a working magic link to an invited user who never set a password', function () { + $user = User::factory()->create([ + 'email' => 'removed-invitee@example.com', + 'email_verified_at' => null, + 'force_password_reset' => true, + ]); + + Livewire::test(InviteLink::class) + ->set('email', $user->email) + ->call('viaLink') + ->assertDispatched('success'); + + $password = invitationMagicLinkPassword(TeamInvitation::whereEmail($user->email)->sole()); + + expect($password)->not->toBeNull() + ->and(Hash::check($password, $user->refresh()->password))->toBeTrue(); +}); + +test('keeps the login link and password of an invited user who can sign in', function (array $attributes) { + $user = User::factory()->create(array_merge(['email' => 'existing-invitee@example.com'], $attributes)); + $passwordHash = $user->password; + if ($attributes['force_password_reset'] ?? false) { + $this->teamA->members()->attach($user, ['role' => 'member']); + } + + Livewire::test(InviteLink::class) + ->set('email', $user->email) + ->call('viaLink') + ->assertDispatched('success'); + + expect(invitationMagicLinkPassword(TeamInvitation::whereEmail($user->email)->sole()))->toBeNull() + ->and($user->refresh()->password)->toBe($passwordHash); +})->with([ + 'own password' => [['force_password_reset' => false]], + 'pending member of another team' => [['force_password_reset' => true, 'email_verified_at' => null]], +]); + +test('keeps the inviter signed in when the invitee password is rotated', function () { + $user = User::factory()->create([ + 'email' => 'rotated-invitee@example.com', + 'email_verified_at' => null, + 'force_password_reset' => true, + ]); + + Livewire::test(InviteLink::class) + ->set('email', $user->email) + ->call('viaLink'); + + $this->assertAuthenticatedAs($this->ownerB); + expect(data_get(session('currentTeam'), 'id'))->toBe($this->teamB->id); +});