From 3063679f1bff828adfe1e58d74f6e635d9924543 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:46:00 +0200 Subject: [PATCH] feat(auth): restore key last-used timestamps FE-2462 FE-4315 (#50732) ## Problem Studio expected aliased fields from the last-used API-key endpoint, but the live endpoint returns OTEL attribute names. This kept legacy API-key activity unavailable and prevented Studio from showing activity for new JWT signing keys. Tracks FE-2462 and FE-4315. ## Fix Normalize the endpoint response at the data boundary, keep the `showApiKeysLastUsed` feature flag, and show activity from the past 24 hours for new JWT signing keys. Legacy HS256 signing keys remain blank because the analytics response does not provide a stable signing-key record ID for them. The request remains hosted-only, permission-gated, and non-blocking, and the existing last-rotated column remains intact. ## How to test - Make a request with a legacy anon or service-role API key, then open Project Settings > API Keys and verify its last request appears. - Make an Auth request signed by a new JWT signing key, then open JWT Keys and verify the matching key shows a Last used timestamp. - Verify a new key without activity shows No requests in the past 24 hours. - Verify the legacy HS256 signing-key row leaves Last used blank. - Expected result: legacy API keys and new JWT signing keys display activity from the shared endpoint without changing self-hosted Studio. ## Summary by CodeRabbit * **New Features** * Added a **Last used** column for JWT signing keys on supported platforms. * Displays usage timestamps, loading and error states, or when a key has had no requests in the past 24 hours. * Usage tracking now includes both API keys and JWT signing keys. * **Bug Fixes** * Improved handling of usage records for legacy and current keys. * Usage details appear only on supported platforms and for users with the required permissions. --------- Co-authored-by: Joshen Lim --- .../jwt-secret-keys-table/index.tsx | 53 ++++++++++++- .../jwt-secret-keys-table/signing-key-row.tsx | 74 ++++++++++++++++++- .../ui/ProjectSettings/DisplayApiSettings.tsx | 17 ++--- .../DisplayApiSettings.utils.test.ts | 8 +- .../DisplayApiSettings.utils.ts | 6 +- .../api-keys-last-used-query.test.ts | 53 +++++++++++++ .../analytics/api-keys-last-used-query.ts | 63 ++++++++++++---- 7 files changed, 236 insertions(+), 38 deletions(-) create mode 100644 apps/studio/data/analytics/api-keys-last-used-query.test.ts diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx index 0d2e42ef1c5..0a9d4f65061 100644 --- a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx @@ -1,8 +1,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' +import { IS_PLATFORM, useFlag, useParams } from 'common' import { AnimatePresence } from 'framer-motion' import { AlertCircle, RotateCw, Timer } from 'lucide-react' -import { useMemo, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { AlertDialog, @@ -37,6 +37,10 @@ import { KeyDetailsDialog } from './key-details-dialog' import { RotateKeyDialog } from './rotate-key-dialog' import { SigningKeyRow } from './signing-key-row' import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' +import { + getJWTSigningKeyLastUsedAt, + useApiKeysLastUsedQuery, +} from '@/data/analytics/api-keys-last-used-query' import { useLegacyAPIKeysStatusQuery } from '@/data/api-keys/legacy-api-keys-status-query' import { useJWTSigningKeyDeleteMutation } from '@/data/jwt-signing-keys/jwt-signing-key-delete-mutation' import { useJWTSigningKeyUpdateMutation } from '@/data/jwt-signing-keys/jwt-signing-key-update-mutation' @@ -59,11 +63,25 @@ export const JWTSecretKeysTable = () => { const [selectedKey, setSelectedKey] = useState() const [selectedKeyToUpdate, setSelectedKeyToUpdate] = useState() const [shownDialog, setShownDialog] = useState() + const showApiKeysLastUsed = useFlag('showApiKeysLastUsed') const { can: canReadAPIKeys, isLoading: isLoadingCanReadAPIKeys } = useAsyncCheckPermissions( PermissionAction.SECRETS_READ, '*' ) + const now = useRef(new Date()).current + const { + data: lastUsedData, + isError: isLastUsedError, + isLoading: isLoadingLastUsed, + } = useApiKeysLastUsedQuery( + { + projectRef, + isoTimestampStart: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(), + isoTimestampEnd: now.toISOString(), + }, + { enabled: canReadAPIKeys && showApiKeysLastUsed } + ) const { data: signingKeys, isPending: isLoadingSigningKeys } = useJWTSigningKeysQuery( { projectRef, @@ -129,6 +147,7 @@ export const JWTSecretKeysTable = () => { () => sortedKeys.filter((key) => key.status === 'revoked'), [sortedKeys] ) + const getLastUsedAt = (keyId: string) => getJWTSigningKeyLastUsedAt(lastUsedData ?? [], keyId) const resetDialog = () => { setSelectedKey(undefined) @@ -236,7 +255,9 @@ export const JWTSecretKeysTable = () => { Type - + + {IS_PLATFORM && showApiKeysLastUsed && 'Last used'} + Actions @@ -257,6 +278,10 @@ export const JWTSecretKeysTable = () => { setShownDialog={setShownDialog} handleStandbyKey={handleStandbyKey} handlePreviouslyUsedKey={handlePreviouslyUsedKey} + lastUsedAt={getLastUsedAt(standbyKey.id)} + isLoadingLastUsed={isLoadingLastUsed} + isLastUsedError={isLastUsedError} + isLastUsedVisible={IS_PLATFORM && showApiKeysLastUsed} /> )} {inUseKey && ( @@ -269,6 +294,10 @@ export const JWTSecretKeysTable = () => { handlePreviouslyUsedKey={handlePreviouslyUsedKey} legacyKey={legacyKey} standbyKey={standbyKey} + lastUsedAt={getLastUsedAt(inUseKey.id)} + isLoadingLastUsed={isLoadingLastUsed} + isLastUsedError={isLastUsedError} + isLastUsedVisible={IS_PLATFORM && showApiKeysLastUsed} /> )} @@ -302,6 +331,11 @@ export const JWTSecretKeysTable = () => { Type + {IS_PLATFORM && showApiKeysLastUsed && ( + + Last used + + )} Last rotated at @@ -323,6 +357,10 @@ export const JWTSecretKeysTable = () => { setShownDialog={setShownDialog} handleStandbyKey={handleStandbyKey} handlePreviouslyUsedKey={handlePreviouslyUsedKey} + lastUsedAt={getLastUsedAt(key.id)} + isLoadingLastUsed={isLoadingLastUsed} + isLastUsedError={isLastUsedError} + isLastUsedVisible={IS_PLATFORM && showApiKeysLastUsed} /> ))} @@ -367,6 +405,11 @@ export const JWTSecretKeysTable = () => { Type + {IS_PLATFORM && showApiKeysLastUsed && ( + + Last used + + )} Last rotated at @@ -387,6 +430,10 @@ export const JWTSecretKeysTable = () => { handlePreviouslyUsedKey={handlePreviouslyUsedKey} legacyKey={legacyKey} standbyKey={standbyKey} + lastUsedAt={getLastUsedAt(key.id)} + isLoadingLastUsed={isLoadingLastUsed} + isLastUsedError={isLastUsedError} + isLastUsedVisible={IS_PLATFORM && showApiKeysLastUsed} /> ))} diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx index e61e4851c95..f962d84bfa6 100644 --- a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx @@ -6,6 +6,7 @@ import { CircleArrowUp, Eye, Key, + Minus, MoreVertical, ShieldOff, Timer, @@ -22,6 +23,7 @@ import { TableCell, TableRow, } from 'ui' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { AlgorithmHoverCard } from '../algorithm-hover-card' @@ -38,10 +40,64 @@ interface SigningKeyRowProps { legacyKey?: JWTSigningKey | null standbyKey?: JWTSigningKey | null isLoading?: boolean + lastUsedAt?: number + isLoadingLastUsed?: boolean + isLastUsedError?: boolean + isLastUsedVisible?: boolean } const MotionTableRow = motion.create(TableRow) +const hasRotationTimestamp = (status: JWTSigningKey['status']) => + status === 'previously_used' || status === 'revoked' + +const LastUsedCell = ({ + lastUsedAt, + isLoading, + isError, + isLastUsedSupported, +}: { + lastUsedAt?: number + isLoading: boolean + isError: boolean + isLastUsedSupported: boolean +}) => { + const className = + 'text-right py-2 text-sm text-foreground-light whitespace-nowrap data-[invisible=true]:invisible' + + if (!isLastUsedSupported) + return ( + + + + ) + + if (isLoading) { + return ( + + + + ) + } + + if (isError) return Unable to load + if (lastUsedAt === undefined) { + return ( + No requests in 24h + ) + } + + return ( + + + + ) +} + export const SigningKeyRow = ({ signingKey, setSelectedKey, @@ -51,6 +107,10 @@ export const SigningKeyRow = ({ legacyKey, standbyKey, isLoading = false, + lastUsedAt, + isLoadingLastUsed = false, + isLastUsedError = false, + isLastUsedVisible = false, }: SigningKeyRowProps) => ( - {signingKey.status === 'previously_used' || signingKey.status === 'revoked' ? ( + {isLastUsedVisible && ( + + )} + {!isLastUsedVisible && !hasRotationTimestamp(signingKey.status) && } + {hasRotationTimestamp(signingKey.status) && ( - ) : ( - )} {(signingKey.status !== 'in_use' || signingKey.algorithm !== 'HS256') && (