mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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:
1 parent
b41ba87fff
commit
6030d096bf
4 files changed
+124
-30
No files matched your search
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user