fix(team): keep member API tokens on promotion

Only revoke a member's team tokens when the role goes down. Promotions
and ownership transfers on account deletion keep the tokens.

Closes #12342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-27 14:37:38 +01:00
co-authored by Claude Opus 5.5
parent 57a839f5a0
commit 25761e29c4
4 changed files with 61 additions and 21 deletions
-1
View File
@@ -137,7 +137,6 @@ class DeleteUserTeams
// Update the new owner's role to owner
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
RevokeUserTeamTokens::forUserTeam($newOwner, $team->id);
// Remove the current user from the team
$team->members()->detach($this->user->id);
+13 -19
View File
@@ -25,12 +25,7 @@ class Member extends Component
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
DB::transaction(function () use ($teamId): void {
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->auditRoleUpdate($teamId, Role::ADMIN);
$this->updateRole(Role::ADMIN);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -46,12 +41,7 @@ class Member extends Component
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
DB::transaction(function () use ($teamId): void {
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->auditRoleUpdate($teamId, Role::OWNER);
$this->updateRole(Role::OWNER);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -67,12 +57,7 @@ class Member extends Component
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
DB::transaction(function () use ($teamId): void {
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->auditRoleUpdate($teamId, Role::MEMBER);
$this->updateRole(Role::MEMBER);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -114,8 +99,17 @@ class Member extends Component
return $this->member->teams()->where('teams.id', currentTeam()->id)->first()?->pivot?->role;
}
private function auditRoleUpdate(int $teamId, Role $role): void
private function updateRole(Role $role): void
{
$teamId = currentTeam()->id;
$previousRole = Role::from($this->getMemberRole());
DB::transaction(function () use ($teamId, $role, $previousRole): void {
$this->member->teams()->updateExistingPivot($teamId, ['role' => $role->value]);
// A promotion keeps the tokens: the API checks the abilities against the role on each request.
if ($role->lt($previousRole)) {
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
}
});
auditLog('ui.team_member.role_updated', [
'team_id' => $teamId,
'member_id' => $this->member->id,
-1
View File
@@ -171,7 +171,6 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail
if ($found_other_member_who_is_not_owner) {
$found_other_member_who_is_not_owner->pivot->role = 'owner';
$found_other_member_who_is_not_owner->pivot->save();
RevokeUserTeamTokens::forUserTeam($found_other_member_who_is_not_owner, $team->id);
$team->members()->detach($user->id);
} else {
static::finalizeTeamDeletion($user, $team);
@@ -1,5 +1,6 @@
<?php
use App\Actions\User\DeleteUserTeams;
use App\Livewire\Security\ApiTokens;
use App\Livewire\Team\Member;
use App\Models\InstanceSettings;
@@ -98,6 +99,53 @@ test('role downgrade through team member component revokes team tokens', functio
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
});
test('owner demotion to admin through team member component revokes team tokens', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'owner']);
$token = $this->user->createToken('write-token', ['write'])->accessToken;
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
Livewire::test(Member::class, ['member' => $this->user])
->call('makeAdmin');
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
});
test('promotion through team member component keeps team tokens', function (string $from, string $action, string $to) {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$this->team->members()->updateExistingPivot($this->user->id, ['role' => $from]);
$token = $this->user->createToken('ci-token', ['read'])->accessToken;
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
Livewire::test(Member::class, ['member' => $this->user])
->call($action)
->assertNotDispatched('error');
expect($this->user->fresh()->teams()->findOrFail($this->team->id)->pivot->role)->toBe($to)
->and(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeTrue();
})->with([
'admin to owner' => ['admin', 'makeOwner', 'owner'],
'member to owner' => ['member', 'makeOwner', 'owner'],
'member to admin' => ['member', 'makeAdmin', 'admin'],
]);
test('ownership transfer on account deletion keeps the new owner tokens', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$token = $this->user->createToken('ci-token', ['write'])->accessToken;
(new DeleteUserTeams($owner))->execute();
expect($this->user->fresh()->teams()->findOrFail($this->team->id)->pivot->role)->toBe('owner')
->and(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeTrue();
});
test('member removal rolls back when token revocation fails', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);