From 548dec65b79672c64bcc4fb14defb3b3d1c8c850 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:40:28 +0100 Subject: [PATCH] feat(api-tokens): warn 7 days before a token expires Co-Authored-By: Claude Opus 5.5 --- app/Jobs/ApiTokenExpirationWarningJob.php | 21 ++++++++++++++++--- .../ApiTokenExpiringNotification.php | 2 +- .../views/components/status-badge.blade.php | 8 ++++++- .../livewire/security/api-tokens.blade.php | 4 ++-- .../Feature/ApiTokenExpirationWarningTest.php | 4 ++-- 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/app/Jobs/ApiTokenExpirationWarningJob.php b/app/Jobs/ApiTokenExpirationWarningJob.php index f59b1a0b17..c32e52af0d 100644 --- a/app/Jobs/ApiTokenExpirationWarningJob.php +++ b/app/Jobs/ApiTokenExpirationWarningJob.php @@ -22,17 +22,19 @@ class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Si public $timeout = 120; + private const WARNING_DAYS = 7; + public function handle(): void { PersonalAccessToken::query() ->whereNotNull('expires_at') ->where('expires_at', '>', now()) - ->where('expires_at', '<=', now()->addDay()) + ->where('expires_at', '<=', now()->addDays(self::WARNING_DAYS)) ->whereNull('api_token_expiration_warning_sent_at') ->where('tokenable_type', User::class) ->chunkById(100, function ($tokens) { foreach ($tokens as $token) { - if (! $token->team_id) { + if (! $token->team_id || ! $this->isDue($token)) { continue; } @@ -53,7 +55,7 @@ class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Si ->whereKey($token->getKey()) ->whereNotNull('expires_at') ->where('expires_at', '>', now()) - ->where('expires_at', '<=', now()->addDay()) + ->where('expires_at', '<=', now()->addDays(self::WARNING_DAYS)) ->whereNull('api_token_expiration_warning_sent_at') ->update(['api_token_expiration_warning_sent_at' => $warningSentAt]); @@ -65,4 +67,17 @@ class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Si } }); } + + /** + * Warn 7 days before expiry. A token that lives 7 days or less gets the warning 24 hours before expiry, + * so it is not warned right after it is created. + */ + private function isDue(PersonalAccessToken $token): bool + { + if ($token->created_at->diffInDays($token->expires_at) > self::WARNING_DAYS) { + return true; + } + + return $token->expires_at->lte(now()->addDay()); + } } diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index bb89d11637..8461a81fbf 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -32,7 +32,7 @@ class ApiTokenExpiringNotification extends CustomEmailNotification public function toMail(): MailMessage { $mail = new MailMessage; - $mail->subject("Coolify: API token '{$this->tokenName}' expires in 24 hours"); + $mail->subject("Coolify: API token '{$this->tokenName}' expires on {$this->expiresAt}"); $mail->view('emails.api-token-expiring', [ 'tokenName' => $this->tokenName, 'expiresAt' => $this->expiresAt, diff --git a/resources/views/components/status-badge.blade.php b/resources/views/components/status-badge.blade.php index a8f49e8bf5..1f1b3089b0 100644 --- a/resources/views/components/status-badge.blade.php +++ b/resources/views/components/status-badge.blade.php @@ -5,6 +5,7 @@ 'as' => 'span', 'dynamic' => false, 'pulse' => false, // ping the dot for a state that is still in progress + 'size' => 'md', // sm fits inline next to 12px table text without growing the row ]) @php @@ -15,7 +16,12 @@ 'error' => 'bg-red-500', ]; - $baseClasses = 'inline-flex h-6 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full border border-neutral-200 bg-neutral-100 px-2 text-xs font-medium leading-none text-neutral-700 dark:border-white/[0.12] dark:bg-white/[0.07] dark:text-white'; + $sizeClasses = [ + 'sm' => 'h-4 gap-1 px-1.5 text-[10px]', + 'md' => 'h-6 gap-1.5 px-2 text-xs', + ]; + + $baseClasses = 'inline-flex max-w-full items-center whitespace-nowrap rounded-full border border-neutral-200 bg-neutral-100 font-medium leading-none text-neutral-700 dark:border-white/[0.12] dark:bg-white/[0.07] dark:text-white ' . ($sizeClasses[$size] ?? $sizeClasses['md']); @endphp @if ($as === 'button') diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 964f57068b..df909b9b07 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -234,7 +234,7 @@
{{ $token->name }} @if ($token->isPaused()) - + @endif
@@ -266,7 +266,7 @@ @if (!$token->expires_at) Never @elseif ($token->expires_at->isPast()) - + @else {{ $token->expires_at->format('Y-m-d') }} @endif diff --git a/tests/Feature/ApiTokenExpirationWarningTest.php b/tests/Feature/ApiTokenExpirationWarningTest.php index a6bbffbbc3..494fb03981 100644 --- a/tests/Feature/ApiTokenExpirationWarningTest.php +++ b/tests/Feature/ApiTokenExpirationWarningTest.php @@ -106,8 +106,8 @@ describe('ApiTokenExpirationWarningJob', function () { Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 2); }); - test('skips tokens expiring more than 24h out', function () { - createTokenExpiring($this->user, $this->team, Carbon::now()->addDays(3)); + test('skips tokens expiring more than 7 days out', function () { + createTokenExpiring($this->user, $this->team, Carbon::now()->addDays(8)); (new ApiTokenExpirationWarningJob)->handle();