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