fix(team): send working invitation links to users without a password

Re-inviting an email after its invitation expired, or re-inviting a user
who never set a password, sent a link that needs a login the user cannot
complete. Unused invited accounts now get a magic link again, and changing
another user's password no longer ends the current session.
This commit is contained in:
Andras Bacsai committed 2026-10-29 17:54:17 +01:00
1 parent b41ba87fff
commit 6030d096bf
4 files changed
+124 -30

No files matched your search

+22 -15
View File
@@ -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,
+13 -11
View File
@@ -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();
}
/**
+7 -4
View File
@@ -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);
}
@@ -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);
});