feat(api-tokens): roll a token to replace its secret

Keep the description, permissions, scopes, and expiry. Move the row actions into a dropdown menu and measure the dropdown panel after it is shown, so menus at the right edge stay on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-30 13:06:43 +01:00
co-authored by Claude Opus 5.5
parent edee5ccfd6
commit 263eab5d27
5 changed files with 82 additions and 20 deletions
+20
View File
@@ -264,6 +264,26 @@ class ApiTokens extends Component
return $scopes;
}
public function roll(int $id)
{
try {
$token = auth()->user()->tokens()
->where('team_id', currentTeam()->id)
->where('id', $id)
->firstOrFail();
$this->authorize('update', $token);
$plainTextToken = $token->roll();
auditLog('ui.api_token.rolled', [
'team_id' => $token->team_id,
'api_token_name' => $token->name,
]);
$this->getTokens();
session()->flash('token', $plainTextToken);
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function revoke(int $id)
{
try {
+24
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Support\Str;
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
class PersonalAccessToken extends SanctumPersonalAccessToken
@@ -23,4 +24,27 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
'scopes' => 'array',
];
}
public static function generateSecret(): string
{
return sprintf(
'%s%s%s',
config('sanctum.token_prefix', ''),
$tokenEntropy = Str::random(40),
hash('crc32b', $tokenEntropy)
);
}
/**
* Replace the secret and keep the abilities, scopes, and expiry. The old secret stops working at once.
*
* @return string the new plain-text token
*/
public function roll(): string
{
$secret = self::generateSecret();
$this->forceFill(['token' => hash('sha256', $secret), 'last_used_at' => null])->save();
return $this->getKey().'|'.$secret;
}
}
+1 -7
View File
@@ -23,7 +23,6 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
@@ -269,12 +268,7 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail
public function createToken(string $name, array $abilities = ['read'], ?DateTimeInterface $expiresAt = null, ?int $teamId = null)
{
$plainTextToken = sprintf(
'%s%s%s',
config('sanctum.token_prefix', ''),
$tokenEntropy = Str::random(40),
hash('crc32b', $tokenEntropy)
);
$plainTextToken = PersonalAccessToken::generateSecret();
$token = $this->tokens()->create([
'name' => $name,
@@ -15,7 +15,7 @@
this.panelStyle = 'position: fixed; min-width: 0; visibility: hidden;';
this.open = true;
this.$nextTick(() => this.updatePosition());
this.$nextTick(() => requestAnimationFrame(() => this.updatePosition()));
},
close() {
this.open = false;
@@ -247,21 +247,45 @@
</div>
<div class="flex justify-end">
@if (auth()->id() === $token->tokenable_id)
<x-modal-confirmation title="Confirm API Token Revocation?"
submitAction="revoke({{ $token->id }})" :actions="[
'This API token will be permanently revoked.',
]"
confirmationText="{{ $token->name }}"
confirmationLabel="Enter the token description to confirm"
shortConfirmationLabel="Token description"
:confirmWithPassword="false" step2ButtonText="Revoke token">
<x-table.dropdown panel-class="w-36!" role="menu">
<x-slot:trigger>
<button type="button"
class="inline-flex h-7 items-center rounded-md px-2 text-[11px] font-medium text-error transition-colors hover:bg-error/10">
Revoke
<button type="button" class="icon-button" title="Token actions"
aria-label="Token actions" aria-haspopup="menu" :aria-expanded="open">
<x-reicon name="dots-horizontal" class="size-4" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
<x-modal-confirmation title="Roll API Token?" buttonFullWidth
submitAction="roll({{ $token->id }})" :actions="[
'A new secret replaces this token. The current secret stops working at once.',
'The description, permissions, scopes, and expiry do not change.',
]"
confirmationText="{{ $token->name }}"
confirmationLabel="Enter the token description to confirm"
shortConfirmationLabel="Token description"
:confirmWithPassword="false" step2ButtonText="Roll token">
<x-slot:trigger>
<button type="button" role="menuitem" x-on:click="close()"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] transition-colors text-neutral-600 hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
Roll
</button>
</x-slot:trigger>
</x-modal-confirmation>
<x-modal-confirmation title="Confirm API Token Revocation?" buttonFullWidth
submitAction="revoke({{ $token->id }})" :actions="[
'This API token will be permanently revoked.',
]"
confirmationText="{{ $token->name }}"
confirmationLabel="Enter the token description to confirm"
shortConfirmationLabel="Token description"
:confirmWithPassword="false" step2ButtonText="Revoke token">
<x-slot:trigger>
<button type="button" role="menuitem" x-on:click="close()"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] transition-colors text-error hover:bg-error/10">
Revoke
</button>
</x-slot:trigger>
</x-modal-confirmation>
</x-table.dropdown>
@endif
</div>
</div>