From 263eab5d27940cc3c730d0aef75e1ee7d8afcbb1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:06:29 +0100 Subject: [PATCH] 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 --- app/Livewire/Security/ApiTokens.php | 20 ++++++++ app/Models/PersonalAccessToken.php | 24 ++++++++++ app/Models/User.php | 8 +--- .../views/components/table/dropdown.blade.php | 2 +- .../livewire/security/api-tokens.blade.php | 48 ++++++++++++++----- 5 files changed, 82 insertions(+), 20 deletions(-) diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 94b6e65507..4501d81c57 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -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 { diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php index b1b60414dc..375187a46a 100644 --- a/app/Models/PersonalAccessToken.php +++ b/app/Models/PersonalAccessToken.php @@ -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; + } } diff --git a/app/Models/User.php b/app/Models/User.php index b771662f5d..b8c82530b4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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, diff --git a/resources/views/components/table/dropdown.blade.php b/resources/views/components/table/dropdown.blade.php index d3c9f3a453..f4de24a992 100644 --- a/resources/views/components/table/dropdown.blade.php +++ b/resources/views/components/table/dropdown.blade.php @@ -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; diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index db953bdc3b..988d83de32 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -247,21 +247,45 @@
@if (auth()->id() === $token->tokenable_id) - + - - + + + + + + + + + + + @endif