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