mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
1 parent
cee7461a9a
commit
3063679f1b
7 files changed
+236
-38
No files matched your search
@@ -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<JWTSigningKey>()
|
||||
const [selectedKeyToUpdate, setSelectedKeyToUpdate] = useState<string>()
|
||||
const [shownDialog, setShownDialog] = useState<DialogType>()
|
||||
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 = () => {
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2">
|
||||
Type
|
||||
</TableHead>
|
||||
<TableHead />
|
||||
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
|
||||
{IS_PLATFORM && showApiKeysLastUsed && 'Last used'}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
|
||||
Actions
|
||||
</TableHead>
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -302,6 +331,11 @@ export const JWTSecretKeysTable = () => {
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2">
|
||||
Type
|
||||
</TableHead>
|
||||
{IS_PLATFORM && showApiKeysLastUsed && (
|
||||
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
|
||||
Last used
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2 hidden lg:table-cell">
|
||||
Last rotated at
|
||||
</TableHead>
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@@ -367,6 +405,11 @@ export const JWTSecretKeysTable = () => {
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2">
|
||||
Type
|
||||
</TableHead>
|
||||
{IS_PLATFORM && showApiKeysLastUsed && (
|
||||
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
|
||||
Last used
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2 hidden lg:table-cell">
|
||||
Last rotated at
|
||||
</TableHead>
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
+71
-3
@@ -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 (
|
||||
<TableCell>
|
||||
<Minus size={14} className="text-foreground-lighter ml-auto" />
|
||||
</TableCell>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<TableCell aria-label="Loading last used timestamp" className={className}>
|
||||
<ShimmeringLoader className="w-14 ml-auto" />
|
||||
</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) return <TableCell className={className}>Unable to load</TableCell>
|
||||
if (lastUsedAt === undefined) {
|
||||
return (
|
||||
<TableCell className={cn(className, 'text-foreground-lighter')}>No requests in 24h</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell className={className}>
|
||||
<TimestampInfo
|
||||
className="text-sm"
|
||||
utcTimestamp={new Date(lastUsedAt).toISOString()}
|
||||
label={dayjs(lastUsedAt).fromNow()}
|
||||
/>
|
||||
</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
export const SigningKeyRow = ({
|
||||
signingKey,
|
||||
setSelectedKey,
|
||||
@@ -51,6 +107,10 @@ export const SigningKeyRow = ({
|
||||
legacyKey,
|
||||
standbyKey,
|
||||
isLoading = false,
|
||||
lastUsedAt,
|
||||
isLoadingLastUsed = false,
|
||||
isLastUsedError = false,
|
||||
isLastUsedVisible = false,
|
||||
}: SigningKeyRowProps) => (
|
||||
<MotionTableRow
|
||||
key={signingKey.id}
|
||||
@@ -107,7 +167,16 @@ export const SigningKeyRow = ({
|
||||
legacy={signingKey.id === legacyKey?.id}
|
||||
/>
|
||||
</TableCell>
|
||||
{signingKey.status === 'previously_used' || signingKey.status === 'revoked' ? (
|
||||
{isLastUsedVisible && (
|
||||
<LastUsedCell
|
||||
lastUsedAt={lastUsedAt}
|
||||
isLoading={isLoadingLastUsed}
|
||||
isError={isLastUsedError}
|
||||
isLastUsedSupported={signingKey.id !== legacyKey?.id}
|
||||
/>
|
||||
)}
|
||||
{!isLastUsedVisible && !hasRotationTimestamp(signingKey.status) && <TableCell />}
|
||||
{hasRotationTimestamp(signingKey.status) && (
|
||||
<TableCell className="max-w-[100px] text-right py-2 text-sm text-foreground-light whitespace-nowrap hidden lg:table-cell">
|
||||
<TimestampInfo
|
||||
className="text-sm"
|
||||
@@ -115,14 +184,13 @@ export const SigningKeyRow = ({
|
||||
label={dayjs(signingKey.updated_at).fromNow()}
|
||||
/>
|
||||
</TableCell>
|
||||
) : (
|
||||
<TableCell />
|
||||
)}
|
||||
<TableCell className="text-right py-2">
|
||||
{(signingKey.status !== 'in_use' || signingKey.algorithm !== 'HS256') && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="More options"
|
||||
variant="text"
|
||||
className="px-1.5"
|
||||
loading={isLoading}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events'
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { IS_PLATFORM, useFlag, useParams } from 'common'
|
||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useRef } from 'react'
|
||||
@@ -53,7 +53,7 @@ export const DisplayApiSettings = ({
|
||||
const isApiKeysEmpty = apiKeys.length === 0
|
||||
|
||||
const now = useRef(new Date()).current
|
||||
const showApiKeyLastUsed = useFlag('showApiKeysLastUsed')
|
||||
const showApiKeysLastUsed = useFlag('showApiKeysLastUsed')
|
||||
const {
|
||||
isLoading: isLoadingLastUsed,
|
||||
isError: isLastUsedError,
|
||||
@@ -64,16 +64,11 @@ export const DisplayApiSettings = ({
|
||||
isoTimestampStart: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
isoTimestampEnd: now.toISOString(),
|
||||
},
|
||||
{ enabled: showApiKeyLastUsed }
|
||||
{ enabled: canReadAPIKeys && showApiKeysLastUsed }
|
||||
)
|
||||
|
||||
const lastUsedAPIKeys = useMemo(() => {
|
||||
if (
|
||||
apiKeys.length < 1 ||
|
||||
!lastUsedLogData ||
|
||||
lastUsedLogData.length < 1 ||
|
||||
!showApiKeyLastUsed
|
||||
) {
|
||||
if (apiKeys.length < 1 || !lastUsedLogData || lastUsedLogData.length < 1) {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -84,7 +79,7 @@ export const DisplayApiSettings = ({
|
||||
console.error(e)
|
||||
return {}
|
||||
}
|
||||
}, [lastUsedLogData, apiKeys, showApiKeyLastUsed])
|
||||
}, [lastUsedLogData, apiKeys])
|
||||
|
||||
return (
|
||||
<Panel
|
||||
@@ -216,7 +211,7 @@ export const DisplayApiSettings = ({
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
{showApiKeyLastUsed && (
|
||||
{IS_PLATFORM && showApiKeysLastUsed && (
|
||||
<div
|
||||
className="pt-2 text-foreground-lighter w-full text-sm data-[invisible=true]:invisible"
|
||||
data-invisible={isLoadingLastUsed}
|
||||
|
||||
@@ -17,7 +17,7 @@ const serviceRoleKey = { tags: 'service_role', api_key: 'header.payload.services
|
||||
describe('getLastUsedAPIKeys', () => {
|
||||
it('returns an empty object when there are no api keys', () => {
|
||||
expect(
|
||||
getLastUsedAPIKeys([], [{ timestamp: 1, role: 'anon', signature_prefix: 'anon' }])
|
||||
getLastUsedAPIKeys([], [{ timestamp: 1, role: 'anon', signaturePrefix: 'anon' }])
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('getLastUsedAPIKeys', () => {
|
||||
{
|
||||
timestamp: dayjs().subtract(2, 'hour').valueOf(),
|
||||
role: 'anon',
|
||||
signature_prefix: 'anonsig',
|
||||
signaturePrefix: 'anonsig',
|
||||
},
|
||||
]
|
||||
)
|
||||
@@ -50,7 +50,7 @@ describe('getLastUsedAPIKeys', () => {
|
||||
{
|
||||
timestamp: dayjs().subtract(1, 'day').valueOf(),
|
||||
role: 'service_role',
|
||||
signature_prefix: 'servicesig',
|
||||
signaturePrefix: 'servicesig',
|
||||
},
|
||||
]
|
||||
)
|
||||
@@ -64,7 +64,7 @@ describe('getLastUsedAPIKeys', () => {
|
||||
[anonKey],
|
||||
[
|
||||
{ timestamp: 1, role: 'anon' },
|
||||
{ timestamp: 2, signature_prefix: 'anonsig' },
|
||||
{ timestamp: 2, signaturePrefix: 'anonsig' },
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ export function getLastUsedAPIKeys(
|
||||
return apiKeys.reduce(
|
||||
(a, i) => {
|
||||
const entry = logData?.find(
|
||||
({ role, signature_prefix }) =>
|
||||
({ role, signaturePrefix }) =>
|
||||
role &&
|
||||
signature_prefix &&
|
||||
signaturePrefix &&
|
||||
i.tags.indexOf(role) >= 0 &&
|
||||
i.api_key.split('.')[2]?.startsWith(signature_prefix)
|
||||
i.api_key.split('.')[2]?.startsWith(signaturePrefix)
|
||||
)?.timestamp
|
||||
|
||||
if (entry) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { apiKeysLastUsedSchema, getJWTSigningKeyLastUsedAt } from './api-keys-last-used-query'
|
||||
|
||||
describe('apiKeysLastUsedSchema', () => {
|
||||
test('normalizes legacy API key and JWT signing key fields from the live endpoint', () => {
|
||||
const rows = apiKeysLastUsedSchema.parse([
|
||||
{
|
||||
request_sb_apikey_apikey_hash: '',
|
||||
request_sb_apikey_apikey_prefix: '',
|
||||
request_sb_jwt_authorization_payload_algorithm: 'HS256',
|
||||
request_sb_jwt_authorization_payload_key_id: '',
|
||||
request_sb_jwt_authorization_payload_role: 'anon',
|
||||
request_sb_jwt_authorization_payload_signature_prefix: 'legacy-signature',
|
||||
timestamp: 100,
|
||||
},
|
||||
{
|
||||
request_sb_apikey_apikey_hash: '',
|
||||
request_sb_apikey_apikey_prefix: '',
|
||||
request_sb_jwt_authorization_payload_algorithm: 'RS256',
|
||||
request_sb_jwt_authorization_payload_key_id: 'signing-key-id',
|
||||
request_sb_jwt_authorization_payload_role: 'authenticated',
|
||||
request_sb_jwt_authorization_payload_signature_prefix: '',
|
||||
timestamp: 200,
|
||||
},
|
||||
{
|
||||
request_sb_jwt_authorization_payload_key_id: 'signing-key-id',
|
||||
request_sb_jwt_authorization_payload_role: 'service_role',
|
||||
timestamp: 300,
|
||||
},
|
||||
])
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
role: 'anon',
|
||||
signaturePrefix: 'legacy-signature',
|
||||
timestamp: 100,
|
||||
},
|
||||
{
|
||||
keyId: 'signing-key-id',
|
||||
role: 'authenticated',
|
||||
timestamp: 200,
|
||||
},
|
||||
{
|
||||
keyId: 'signing-key-id',
|
||||
role: 'service_role',
|
||||
timestamp: 300,
|
||||
},
|
||||
])
|
||||
|
||||
expect(getJWTSigningKeyLastUsedAt(rows, 'signing-key-id')).toBe(300)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { analyticsKeys } from './keys'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import { UseCustomQueryOptions } from '@/types'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import type { UseCustomQueryOptions } from '@/types'
|
||||
|
||||
export type ApiKeysLastUsedVariables = {
|
||||
projectRef?: string
|
||||
@@ -10,13 +12,47 @@ export type ApiKeysLastUsedVariables = {
|
||||
isoTimestampEnd?: string
|
||||
}
|
||||
|
||||
// One row per (role, signature_prefix). `timestamp` is unix millis of the most
|
||||
// recent edge-log entry seen for that anon / service_role JWT api key fingerprint.
|
||||
export type ApiKeyLastUsed = {
|
||||
timestamp: number
|
||||
role?: 'anon' | 'service_role' | string
|
||||
signature_prefix?: string
|
||||
}
|
||||
const apiKeyLastUsedSchema = z.object({
|
||||
timestamp: z.number(),
|
||||
role: z.string().optional(),
|
||||
signaturePrefix: z.string().optional(),
|
||||
keyId: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ApiKeyLastUsed = z.infer<typeof apiKeyLastUsedSchema>
|
||||
|
||||
const apiKeyLastUsedEndpointRowSchema = z
|
||||
.object({
|
||||
timestamp: z.number().finite(),
|
||||
role: z.string().nullish(),
|
||||
signature_prefix: z.string().nullish(),
|
||||
key_id: z.string().nullish(),
|
||||
request_sb_jwt_authorization_payload_role: z.string().nullish(),
|
||||
request_sb_jwt_authorization_payload_signature_prefix: z.string().nullish(),
|
||||
request_sb_jwt_authorization_payload_key_id: z.string().nullish(),
|
||||
})
|
||||
.transform((row): ApiKeyLastUsed => {
|
||||
const role = row.role || row.request_sb_jwt_authorization_payload_role
|
||||
const signaturePrefix =
|
||||
row.signature_prefix || row.request_sb_jwt_authorization_payload_signature_prefix
|
||||
const keyId = row.key_id || row.request_sb_jwt_authorization_payload_key_id
|
||||
|
||||
const apiKeyLastUsed: ApiKeyLastUsed = { timestamp: row.timestamp }
|
||||
if (role) apiKeyLastUsed.role = role
|
||||
if (signaturePrefix) apiKeyLastUsed.signaturePrefix = signaturePrefix
|
||||
if (keyId) apiKeyLastUsed.keyId = keyId
|
||||
return apiKeyLastUsed
|
||||
})
|
||||
.pipe(apiKeyLastUsedSchema)
|
||||
|
||||
export const apiKeysLastUsedSchema = z.array(apiKeyLastUsedEndpointRowSchema)
|
||||
|
||||
export const getJWTSigningKeyLastUsedAt = (rows: ApiKeyLastUsed[], keyId: string) =>
|
||||
rows.reduce<number | undefined>((latestTimestamp, row) => {
|
||||
if (row.keyId !== keyId) return latestTimestamp
|
||||
if (latestTimestamp === undefined) return row.timestamp
|
||||
return Math.max(latestTimestamp, row.timestamp)
|
||||
}, undefined)
|
||||
|
||||
export async function getApiKeysLastUsed(
|
||||
{ projectRef, isoTimestampStart, isoTimestampEnd }: ApiKeysLastUsedVariables,
|
||||
@@ -42,18 +78,17 @@ export async function getApiKeysLastUsed(
|
||||
|
||||
if (error) handleError(error)
|
||||
|
||||
const response = data as { error?: string | object | null; result?: unknown[] }
|
||||
if (response?.error) {
|
||||
if (data?.error) {
|
||||
throw new Error(
|
||||
typeof response.error === 'string' ? response.error : 'Failed to fetch last-used API keys'
|
||||
typeof data.error === 'string' ? data.error : 'Failed to fetch last-used API keys'
|
||||
)
|
||||
}
|
||||
|
||||
return (response?.result ?? []) as ApiKeyLastUsed[]
|
||||
return apiKeysLastUsedSchema.parse(data?.result ?? [])
|
||||
}
|
||||
|
||||
export type ApiKeysLastUsedData = Awaited<ReturnType<typeof getApiKeysLastUsed>>
|
||||
export type ApiKeysLastUsedError = unknown
|
||||
export type ApiKeysLastUsedError = Error
|
||||
|
||||
export const useApiKeysLastUsedQuery = <TData = ApiKeysLastUsedData>(
|
||||
{ projectRef, isoTimestampStart, isoTimestampEnd }: ApiKeysLastUsedVariables,
|
||||
@@ -66,6 +101,6 @@ export const useApiKeysLastUsedQuery = <TData = ApiKeysLastUsedData>(
|
||||
queryKey: analyticsKeys.apiKeysLastUsed(projectRef, { isoTimestampStart, isoTimestampEnd }),
|
||||
queryFn: ({ signal }) =>
|
||||
getApiKeysLastUsed({ projectRef, isoTimestampStart, isoTimestampEnd }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
enabled: IS_PLATFORM && enabled && typeof projectRef !== 'undefined',
|
||||
...options,
|
||||
})
|
||||
Reference in new issue
Block a user