From 97cc348179880473fc9dc960b6f841221f2d0f23 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=CE=A3rebe=20-=20Romain=20GERARD?=
Date: Wed, 14 Oct 2026 12:54:08 +0200
Subject: [PATCH] feat(api-tokens): show last usage in token lists
Help users assess token activity directly from organization settings when
reviewing credentials to keep or remove.
Show Last used beside creation details for regular and policy API tokens.
Format the timestamp as a human-readable age relative to when the list
loads, keeping that reference stable across rerenders. Show the exact UTC
timestamp on hover and No recorded usage when the value is null or absent.
Extend the list prop types with optional last_used_at support while the
pinned generated API client does not yet declare the field.
Test relative formatting, placement, hover timestamps, missing values, and
the fixed reference time across rerenders for both lists.
---
.../policy-api-token-list.spec.tsx | 44 +++++++++++++++++
.../policy-api-token-list.tsx | 10 +++-
.../settings-api-token.spec.tsx | 47 +++++++++++++++++++
.../settings-api-token/settings-api-token.tsx | 13 +++--
package.json | 2 +-
yarn.lock | 10 ++--
6 files changed, 116 insertions(+), 10 deletions(-)
diff --git a/libs/domains/organizations/feature/src/lib/settings-api-token/policy-api-token/policy-api-token-list.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-api-token/policy-api-token/policy-api-token-list.spec.tsx
index 35a7826bbed..31d486a01fa 100644
--- a/libs/domains/organizations/feature/src/lib/settings-api-token/policy-api-token/policy-api-token-list.spec.tsx
+++ b/libs/domains/organizations/feature/src/lib/settings-api-token/policy-api-token/policy-api-token-list.spec.tsx
@@ -1,4 +1,6 @@
import { type OrganizationPolicyApiToken } from 'qovery-typescript-axios'
+import { useState } from 'react'
+import { dateUTCString } from '@qovery/shared/util-dates'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { PolicyApiTokenList } from './policy-api-token-list'
@@ -31,6 +33,48 @@ describe('PolicyApiTokenList', () => {
jest.clearAllMocks()
})
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it('should show last usage next to creation, relative to when the list loaded', async () => {
+ jest.useFakeTimers().setSystemTime(new Date('2026-10-13T12:00:00Z'))
+ const lastUsedAt = '2026-10-13T11:55:00Z'
+ const tokens = [{ ...policyApiToken, last_used_at: lastUsedAt }]
+ const onDelete = jest.fn()
+
+ function TestPage() {
+ const [renderCount, setRenderCount] = useState(0)
+ return (
+ <>
+
+
+ >
+ )
+ }
+
+ const { userEvent } = renderWithProviders()
+
+ const lastUsed = screen.getByText('Last used 5 minutes ago')
+ expect(lastUsed).toHaveAttribute('title', dateUTCString(lastUsedAt))
+ expect(lastUsed.previousElementSibling).toHaveTextContent('Created since')
+
+ jest.setSystemTime(new Date('2026-10-13T13:00:00Z'))
+ await userEvent.click(screen.getByRole('button', { name: 'Render 0' }))
+
+ expect(screen.getByRole('button', { name: 'Render 1' })).toBeInTheDocument()
+ expect(screen.getByText('Last used 5 minutes ago')).toBeInTheDocument()
+ })
+
+ it.each([null, undefined])('should hide last usage when the timestamp is %s', (lastUsedAt) => {
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByTestId(`policy-token-list-${policyApiToken.id}`)).toBeInTheDocument()
+ expect(screen.queryByText(/Last used/)).not.toBeInTheDocument()
+ })
+
it('should render an empty state', () => {
renderWithProviders()
diff --git a/libs/domains/organizations/feature/src/lib/settings-api-token/policy-api-token/policy-api-token-list.tsx b/libs/domains/organizations/feature/src/lib/settings-api-token/policy-api-token/policy-api-token-list.tsx
index f9ee442ada6..67103cdf219 100644
--- a/libs/domains/organizations/feature/src/lib/settings-api-token/policy-api-token/policy-api-token-list.tsx
+++ b/libs/domains/organizations/feature/src/lib/settings-api-token/policy-api-token/policy-api-token-list.tsx
@@ -1,4 +1,6 @@
+import { formatDistanceStrict } from 'date-fns'
import { type OrganizationPolicyApiToken } from 'qovery-typescript-axios'
+import { useState } from 'react'
import { Button, EmptyState, Icon, Skeleton, Tooltip, Truncate, useModal } from '@qovery/shared/ui'
import { dateMediumLocalFormat, dateUTCString } from '@qovery/shared/util-dates'
import { upperCaseFirstLetter } from '@qovery/shared/util-js'
@@ -12,6 +14,7 @@ export interface PolicyApiTokenListProps {
export function PolicyApiTokenList({ policyApiTokens, onDelete }: PolicyApiTokenListProps) {
const { openModal, closeModal } = useModal()
+ const [loadedAt] = useState(() => new Date())
if (policyApiTokens.length === 0) {
return (
@@ -53,10 +56,15 @@ export function PolicyApiTokenList({ policyApiTokens, onDelete }: PolicyApiToken
)}
{token.creator_name && Created by {token.creator_name}}
{token.created_at && (
-
+
Created since {dateMediumLocalFormat(token.created_at)}
)}
+ {token.last_used_at && (
+
+ Last used {formatDistanceStrict(new Date(token.last_used_at), loadedAt, { addSuffix: true })}
+
+ )}
diff --git a/libs/domains/organizations/feature/src/lib/settings-api-token/settings-api-token.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-api-token/settings-api-token.spec.tsx
index cb19ed636c7..2b531fb558c 100644
--- a/libs/domains/organizations/feature/src/lib/settings-api-token/settings-api-token.spec.tsx
+++ b/libs/domains/organizations/feature/src/lib/settings-api-token/settings-api-token.spec.tsx
@@ -1,4 +1,6 @@
import { type OrganizationApiToken } from 'qovery-typescript-axios'
+import { useState } from 'react'
+import { dateUTCString } from '@qovery/shared/util-dates'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import * as useApiTokensModule from '../hooks/use-api-tokens/use-api-tokens'
import * as useDeleteApiTokenModule from '../hooks/use-delete-api-token/use-delete-api-token'
@@ -37,6 +39,51 @@ describe('SettingsApiToken', () => {
})
})
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it('should show last usage next to creation, relative to when the list loaded', async () => {
+ jest.useFakeTimers().setSystemTime(new Date('2026-10-13T12:00:00Z'))
+ const lastUsedAt = '2026-10-13T10:00:00Z'
+ useApiTokensMockSpy.mockReturnValue({
+ data: [{ id: 'token-1', name: 'My token', created_at: '2024-01-01T00:00:00Z', last_used_at: lastUsedAt }],
+ })
+
+ function TestPage() {
+ const [renderCount, setRenderCount] = useState(0)
+ return (
+ <>
+
+
+ >
+ )
+ }
+
+ const { userEvent } = renderWithProviders(
)
+
+ const lastUsed = screen.getByText('Last used 2 hours ago')
+ expect(lastUsed).toHaveAttribute('title', dateUTCString(lastUsedAt))
+ expect(lastUsed.previousElementSibling).toHaveTextContent('Created since')
+
+ jest.setSystemTime(new Date('2026-10-13T13:00:00Z'))
+ await userEvent.click(screen.getByRole('button', { name: 'Render 0' }))
+
+ expect(screen.getByRole('button', { name: 'Render 1' })).toBeInTheDocument()
+ expect(screen.getByText('Last used 2 hours ago')).toBeInTheDocument()
+ })
+
+ it.each([null, undefined])('should hide last usage when the timestamp is %s', (lastUsedAt) => {
+ useApiTokensMockSpy.mockReturnValue({
+ data: [{ id: 'token-1', name: 'My token', last_used_at: lastUsedAt }],
+ })
+
+ renderWithProviders(
)
+
+ expect(screen.getByTestId('token-list-token-1')).toBeInTheDocument()
+ expect(screen.queryByText(/Last used/)).not.toBeInTheDocument()
+ })
+
it('should render empty state', () => {
useApiTokensMockSpy.mockReturnValue({ data: [] })
diff --git a/libs/domains/organizations/feature/src/lib/settings-api-token/settings-api-token.tsx b/libs/domains/organizations/feature/src/lib/settings-api-token/settings-api-token.tsx
index c794dc8bcc6..ef5d0cd1694 100644
--- a/libs/domains/organizations/feature/src/lib/settings-api-token/settings-api-token.tsx
+++ b/libs/domains/organizations/feature/src/lib/settings-api-token/settings-api-token.tsx
@@ -1,6 +1,7 @@
import { useParams } from '@tanstack/react-router'
+import { formatDistanceStrict } from 'date-fns'
import { type OrganizationApiToken } from 'qovery-typescript-axios'
-import { Suspense } from 'react'
+import { Suspense, useState } from 'react'
import { SettingsHeading } from '@qovery/shared/console-shared'
import { useModal, useModalConfirmation } from '@qovery/shared/ui'
import { BlockContent, Button, Icon, Section, Skeleton, Tooltip, Truncate } from '@qovery/shared/ui'
@@ -18,13 +19,14 @@ interface PageOrganizationApiProps {
function PageOrganizationApi(props: PageOrganizationApiProps) {
const { apiTokens, onDelete } = props
+ const [loadedAt] = useState(() => new Date())
return (
{apiTokens.length > 0 ? (
- {apiTokens.map((token: OrganizationApiToken) => (
+ {apiTokens.map((token) => (
-
Role: {upperCaseFirstLetter(token.role_name)}
{token.created_at && (
-
+
Created since {dateMediumLocalFormat(token.created_at)}
)}
+ {token.last_used_at && (
+
+ Last used {formatDistanceStrict(new Date(token.last_used_at), loadedAt, { addSuffix: true })}
+
+ )}
diff --git a/package.json b/package.json
index 25c491e7ab9..e7af72cca59 100644
--- a/package.json
+++ b/package.json
@@ -78,7 +78,7 @@
"mermaid": "11.6.0",
"monaco-editor": "0.53.0",
"posthog-js": "1.345.1",
- "qovery-typescript-axios": "1.1.1010",
+ "qovery-typescript-axios": "1.1.1011",
"react": "19.3.0",
"react-country-flag": "3.0.2",
"react-datepicker": "4.12.0",
diff --git a/yarn.lock b/yarn.lock
index 06eda2225d7..6a9c4a5161a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6285,7 +6285,7 @@ __metadata:
prettier: 3.2.5
prettier-plugin-tailwindcss: 0.5.14
pretty-quick: 4.0.0
- qovery-typescript-axios: 1.1.1010
+ qovery-typescript-axios: 1.1.1011
qovery-ws-typescript-axios: 0.1.666
react: 19.3.0
react-country-flag: 3.0.2
@@ -24656,12 +24656,12 @@ __metadata:
languageName: node
linkType: hard
-"qovery-typescript-axios@npm:1.1.1010":
- version: 1.1.1010
- resolution: "qovery-typescript-axios@npm:1.1.1010"
+"qovery-typescript-axios@npm:1.1.1011":
+ version: 1.1.1011
+ resolution: "qovery-typescript-axios@npm:1.1.1011"
dependencies:
axios: 1.20.0
- checksum: a1957c4f49a2c1d131d3826751e0e650f8494c8819e8d1dba0f0989d69f76f7ef6588a3636e950a92cfb5c46b8380fa17863a47ced60d046f181ca31c1e94c01
+ checksum: 2cb3f535fab0de6a47beae6e800871eaa3b075aa2258d78e8f2addb45f2e352db7f6e56e989c10e68a3ec6b770dd73e297238faf91678f36afb3f015ffe80783
languageName: node
linkType: hard