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
2026-10-29 17:54:17 +01:00
parent b41ba87fff
commit 6030d096bf
4 changed files with 124 additions and 30 deletions
+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);
}