Files
supabase/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx
Jordi EnricandClaude Opus 4.8 4096267623 feat(api-keys): migrate last-used indicator to ClickHouse endpoint (#47458)
## Problem

The "last used" indicator for the legacy `anon` / `service_role` API
keys (Project API keys settings) was disabled because it ran a BigQuery
`edge_logs` query. It is now re-enabled against the ClickHouse-backed
`api_keys.last_used.otel` analytics endpoint.

## Current behavior

- The `anon` / `service_role` "last used" indicator is off (the
BigQuery-backed query was disabled).

## New behavior

- New `useApiKeysLastUsedQuery` hook calls the `api_keys.last_used.otel`
endpoint (timestamp params only, no SQL sent), plus its query key and
the generated platform API type.
- `DisplayApiSettings` reads last-used from this hook instead of posting
BigQuery `edge_logs` SQL. The pure `getLastUsedAPIKeys` shaper is kept
and unit-tested. Still gated by the `showApiKeysLastUsed` flag.
- Removed the disabled secret-keys (`sb_secret_`) BigQuery last-used
path, which has no ClickHouse endpoint to migrate to: drops the dead
`useLastSeen` query, the `APIKeyRow` "Last Used" column, and the unused
`showLastSeen` prop.
- Reworded the delete-confirmation copy to be accurate for both secret
and publishable keys.

## Additional context

- Backed by the platform endpoint in supabase/platform#34892 (merged and
deployed).
- Scope: `anon` / `service_role` legacy keys. Secret/publishable and JWT
signing-key "last used" are follow-ups, pending the endpoint returning
those key types.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
* Updated API key settings to show “last used” activity for the past 24
hours using a dedicated data source and time window.
  * Added clearer messaging when recent API key activity fails to load.
  * Removed the “Last Used” column from API key management tables.
* **Bug Fixes**
* Improved mapping so “last used” values correctly match the intended
key and role.
* Updated API key deletion confirmation to explain required backend
changes and resulting unauthorized behavior.
* **Tests**
* Added unit tests to validate “last used” computation and edge-case
filtering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 14:35:32 +02:00

249 lines
9.2 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events'
import { useFlag, useParams } from 'common'
import { AlertCircle, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useMemo, useRef } from 'react'
import { toast } from 'sonner'
import { Input } from 'ui-patterns/DataInputs/Input'
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
import { getLastUsedAPIKeys } from './DisplayApiSettings.utils'
import Panel from '@/components/ui/Panel'
import { useApiKeysLastUsedQuery } from '@/data/analytics/api-keys-last-used-query'
import { useJwtSecretUpdatingStatusQuery } from '@/data/config/jwt-secret-updating-status-query'
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
export const DisplayApiSettings = ({
showTitle = true,
showNotice = true,
showLegacyText = true,
}: {
showTitle?: boolean
showNotice?: boolean
showLegacyText?: boolean
}) => {
const { ref: projectRef } = useParams()
const {
data: settings,
isError: isProjectSettingsError,
isPending: isProjectSettingsLoading,
} = useProjectSettingsV2Query({ projectRef })
const {
data,
isError: isJwtSecretUpdateStatusError,
isPending: isJwtSecretUpdateStatusLoading,
} = useJwtSecretUpdatingStatusQuery({ projectRef })
const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus
const { isLoading: isLoadingPermissions, can: canReadAPIKeys } = useAsyncCheckPermissions(
PermissionAction.READ,
'service_api_keys'
)
const isLoading = isProjectSettingsLoading || isLoadingPermissions
const isNotUpdatingJwtSecret =
jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated
const apiKeys = useMemo(() => settings?.service_api_keys ?? [], [settings])
// api keys should not be empty. However it can be populated with a delay on project creation
const isApiKeysEmpty = apiKeys.length === 0
const now = useRef(new Date()).current
const showApiKeyLastUsed = useFlag('showApiKeysLastUsed')
const {
isLoading: isLoadingLastUsed,
isError: isLastUsedError,
data: lastUsedLogData,
} = useApiKeysLastUsedQuery(
{
projectRef,
isoTimestampStart: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(),
isoTimestampEnd: now.toISOString(),
},
{ enabled: showApiKeyLastUsed }
)
const lastUsedAPIKeys = useMemo(() => {
if (
apiKeys.length < 1 ||
!lastUsedLogData ||
lastUsedLogData.length < 1 ||
!showApiKeyLastUsed
) {
return {}
}
try {
return getLastUsedAPIKeys(apiKeys, lastUsedLogData)
} catch (e: any) {
toast.error('Failed to identify when the anon and service_role keys were last used')
console.error(e)
return {}
}
}, [lastUsedLogData, apiKeys, showApiKeyLastUsed])
return (
<Panel
noMargin
title={
showTitle && (
<div className="space-y-3">
<h5 className="text-base">Project API Keys</h5>
<p className="text-sm text-foreground-light">
Your API is secured behind an API gateway which requires an API Key for every request.
<br />
You can use the keys below in the Supabase client libraries.
<br />
</p>
</div>
)
}
>
{isLoading ? (
<div className="flex items-center justify-center py-8 space-x-2">
<Loader2 className="animate-spin" size={16} strokeWidth={1.5} />
<p className="text-sm text-foreground-light">Retrieving API keys</p>
</div>
) : !canReadAPIKeys ? (
<div className="flex items-center py-8 px-8 space-x-2">
<AlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-foreground-light">
You don't have permission to view API keys. These keys restricted to users with higher
access levels.
</p>
</div>
) : isProjectSettingsError || isJwtSecretUpdateStatusError ? (
<div className="flex items-center justify-center py-8 space-x-2">
<AlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-foreground-light">
{isProjectSettingsError ? 'Failed to retrieve API keys' : 'Failed to update JWT secret'}
</p>
</div>
) : isApiKeysEmpty || isProjectSettingsLoading || isJwtSecretUpdateStatusLoading ? (
<div className="flex items-center justify-center py-8 space-x-2">
<Loader2 className="animate-spin" size={16} strokeWidth={1.5} />
<p className="text-sm text-foreground-light">
{isProjectSettingsLoading || isApiKeysEmpty
? 'Retrieving API keys'
: 'JWT secret is being updated'}
</p>
</div>
) : (
apiKeys.map((x, i: number) => (
<Panel.Content
key={x.api_key}
className={
i >= 1 &&
'border-t border-panel-border-interior-light in-data-[theme*=dark]:border-panel-border-interior-dark'
}
>
<FormLayout
layout="horizontal"
label={
<div className="flex items-center space-x-1">
{x.tags?.split(',').map((x, i: number) => (
<code key={`${x}${i}`} className="text-code-inline">
{x}
</code>
))}
{x.tags === 'service_role' && (
<>
<code className="text-code-inline bg-destructive! text-white! border-destructive!">
secret
</code>
</>
)}
{x.tags === 'anon' && <code className="text-code-inline">public</code>}
</div>
}
description={
x.tags === 'service_role' ? (
<>
This key has the ability to bypass Row Level Security. Never share it publicly.
If leaked, generate a new JWT secret immediately.{' '}
{showLegacyText && (
<span>
Prefer using{' '}
<Link
href={`/project/${projectRef}/settings/api-keys/new`}
className="text-link underline"
>
Secret API keys
</Link>{' '}
instead.
</span>
)}
</>
) : (
<>
This key is safe to use in a browser if you have enabled Row Level Security for
your tables and configured policies.{' '}
{showLegacyText && (
<span>
Prefer using{' '}
<Link
href={`/project/${projectRef}/settings/api-keys/new`}
className="text-link underline"
>
Publishable API keys
</Link>{' '}
instead.
</span>
)}
</>
)
}
>
<Input
readOnly
className="font-mono"
copy={canReadAPIKeys && isNotUpdatingJwtSecret}
reveal={x.tags !== 'anon' && canReadAPIKeys && isNotUpdatingJwtSecret}
value={
!canReadAPIKeys
? 'You need additional permissions to view API keys'
: jwtSecretUpdateStatus === JwtSecretUpdateStatus.Failed
? 'JWT secret update failed, new API key may have issues'
: jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating
? 'Updating JWT secret...'
: (x?.api_key ?? 'You need additional permissions to view API keys')
}
onChange={() => {}}
/>
</FormLayout>
{showApiKeyLastUsed && (
<div
className="pt-2 text-foreground-lighter w-full text-sm data-[invisible=true]:invisible"
data-invisible={isLoadingLastUsed}
>
{isLastUsedError
? 'Unable to load requests from the past 24 hours.'
: lastUsedAPIKeys[x.api_key]
? `Last request was ${lastUsedAPIKeys[x.api_key]} ago.`
: 'No requests in the past 24 hours.'}
</div>
)}
</Panel.Content>
))
)}
{showNotice ? (
<Panel.Notice
className="border-t"
title="API keys have moved"
badgeLabel="Changelog"
description={`
\`anon\` and \`service_role\` API keys can now be replaced with \`publishable\` and \`secret\` API keys.
`}
href="https://github.com/orgs/supabase/discussions/29260"
buttonText="Read the announcement"
/>
) : null}
</Panel>
)
}