fix: more api key ui fixes (#36300)

This commit is contained in:
Stojan Dimitrovski authored and GitHub committed 2025-06-10 16:36:34 +02:00
1 parent 257e04d714
commit 515359c3bc
7 files changed
+121 -45

No files matched your search

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { Trash2 } from 'lucide-react'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common/hooks'
@@ -11,9 +12,10 @@ import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
interface APIKeyDeleteDialogProps {
apiKey: Extract<APIKeysData[number], { type: 'secret' | 'publishable' }>
lastSeen?: { timestamp: string }
}
export const APIKeyDeleteDialog = ({ apiKey }: APIKeyDeleteDialogProps) => {
export const APIKeyDeleteDialog = ({ apiKey, lastSeen }: APIKeyDeleteDialogProps) => {
const { ref: projectRef } = useParams()
const [isOpen, setIsOpen] = useState(false)
@@ -42,22 +44,23 @@ export const APIKeyDeleteDialog = ({ apiKey }: APIKeyDeleteDialogProps) => {
}
}}
>
Delete API key
<Trash2 className="size-4 text-destructive" strokeWidth={1.5} /> Delete API key
</DropdownMenuItem>
<TextConfirmModal
visible={isOpen}
onCancel={() => setIsOpen(false)}
onConfirm={onDeleteAPIKey}
title={`Delete ${apiKey.description ?? ''} API secret key`}
confirmString={apiKey.description || 'Delete API secret key'}
confirmLabel="Delete API secret key"
confirmPlaceholder="Type API key description to confirm"
title={`Delete ${apiKey.type} API key: ${apiKey.name}`}
confirmString={apiKey.name}
confirmLabel="Yes, irreversibly delete this API key"
confirmPlaceholder="Type the name of the API key to confirm"
loading={isDeletingAPIKey}
variant="destructive"
alert={{
title: 'This cannot be undone',
description:
'Deleting this API key will invalidate it immediately. Any applications using this key will no longer be able to access this project.',
description: lastSeen
? `This API key was used ${lastSeen.timestamp}. Make sure all backend components using it have been updated. Deletion will cause them to receive HTTP 401 Unauthorized status codes on all Supabase APIs.`
: `This API key has not been used in the past 24 hours. Make sure you've updated all backend components using it before deletion.`,
}}
/>
</>
@@ -14,8 +14,10 @@ import { APIKeysData } from 'data/api-keys/api-keys-query'
export const APIKeyRow = ({
apiKey,
lastSeen,
}: {
apiKey: Extract<APIKeysData[number], { type: 'secret' | 'publishable' }>
lastSeen?: { timestamp: string }
}) => {
const MotionTableRow = motion(TableRow)
@@ -39,6 +41,7 @@ export const APIKeyRow = ({
</div>
</TableCell>
<TableCell className="py-2">{apiKey.description || '/'}</TableCell>
<TableCell className="py-2">{lastSeen?.timestamp ?? '/'}</TableCell>
<TableCell className="flex justify-end">
<DropdownMenu>
@@ -52,7 +55,7 @@ export const APIKeyRow = ({
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-40" align="end">
<APIKeyDeleteDialog apiKey={apiKey} />
<APIKeyDeleteDialog apiKey={apiKey} lastSeen={lastSeen} />
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -116,7 +116,7 @@ export function ApiKeyPill({
isSecret ? 'overflow-hidden' : '',
show ? 'ring-1 ring-foreground-lighter ring-opacity-50' : 'ring-0 ring-opacity-0',
'transition-all',
'max-w-[380px]',
'max-w-[340px]',
'cursor-text',
'relative'
)}
@@ -16,18 +16,34 @@ import {
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Alert,
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation'
import { Plus } from 'lucide-react'
import { Plus, ShieldCheck } from 'lucide-react'
const NAME_SCHEMA = z
.string()
.min(4, 'Name must be at least 4 characters')
.max(64, "Name can't be more than 64 characters long")
.regex(/^[a-z0-9_]+$/, 'Name can only contain lowercased letters, digits and underscore')
.refine((val: string) => !val.match(/^[0-9].+$/), 'Name must not start with a digit')
.refine(
(val: string) => val !== 'anon' && val !== 'service_role',
'Using "anon" or "service_role" for API key name is not possible'
)
const FORM_ID = 'create-secret-api-key'
const SCHEMA = z.object({
name: z.string(),
description: z.string().trim(),
name: NAME_SCHEMA,
description: z.string().max(256, "Description shouldn't be too long").trim(),
})
const CreateSecretAPIKeyDialog = () => {
@@ -57,7 +73,8 @@ const CreateSecretAPIKeyDialog = () => {
description: values.description,
},
{
onSuccess: () => {
onSuccess: (data) => {
toast.success(`Your secret API key ${data.prefix}... is ready.`)
onClose(false)
},
}
@@ -68,7 +85,7 @@ const CreateSecretAPIKeyDialog = () => {
<Dialog open={visible} onOpenChange={onClose}>
<DialogTrigger asChild>
<Button type="default" className="mt-2" icon={<Plus />}>
Add new Secret key
Add new secret key
</Button>
</DialogTrigger>
<DialogContent>
@@ -76,12 +93,8 @@ const CreateSecretAPIKeyDialog = () => {
<DialogTitle>Create new secret API key</DialogTitle>
<DialogDescription className="grid gap-y-2">
<p>
Secret API keys are used to authorize requests to your project from servers,
functions, workers or other backend components of your application.{' '}
</p>
<p>
Keep these keys private. Don't publish them online or commit them to source control.
Secret API keys allow elevated access to your project's data, bypassing Row-Level
security.
</p>
</DialogDescription>
</DialogHeader>
@@ -100,10 +113,10 @@ const CreateSecretAPIKeyDialog = () => {
render={({ field }) => (
<FormItemLayout
label="Name"
description="A short name of lowercase alphanumeric characters and underscore, must start with letter or underscore."
description="A short, unique name of lowercased letters, digits and underscore"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
<Input_Shadcn_ {...field} placeholder="Example: my_super_secret_key_123" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
@@ -113,18 +126,39 @@ const CreateSecretAPIKeyDialog = () => {
name="description"
control={form.control}
render={({ field }) => (
<FormItemLayout
label="Description"
description="Provide a description about what this key is used for."
>
<FormItemLayout label="Description" labelOptional="Optional">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="(Optional)" />
<Input_Shadcn_
{...field}
placeholder="Short notes on how or where this key will be used"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</form>
</Form_Shadcn_>
<Alert_Shadcn_ variant="warning">
<ShieldCheck />
<AlertTitle_Shadcn_>Securing your API key</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="">
<ul className="list-disc">
<li>Keep this key secret.</li>
<li>Do not use on the web, in mobile or desktop apps.</li>
<li>Don't post it publicly or commit in source control.</li>
<li>
This key provides elevated access to your data, bypassing Row-Level Security.
</li>
<li>
If it leaks or is revealed, swap it with a new secret API key and then delete it.
</li>
<li>
If used in a browser, it will always return HTTP 401 Unauthorized. Delete
immediately.
</li>
</ul>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</DialogSection>
<DialogFooter>
<Button form={FORM_ID} htmlType="submit" loading={isCreatingAPIKey}>
@@ -1,10 +1,15 @@
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useMemo } from 'react'
import { useMemo, useRef } from 'react'
import { useParams } from 'common'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { APIKeysData, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import useLogsQuery from 'hooks/analytics/useLogsQuery'
import { Card, CardContent, EyeOffIcon, Skeleton, WarningIcon, cn } from 'ui'
import {
Table,
@@ -17,6 +22,38 @@ import {
import { APIKeyRow } from './APIKeyRow'
import CreateSecretAPIKeyDialog from './CreateSecretAPIKeyDialog'
dayjs.extend(duration)
dayjs.extend(relativeTime)
interface LastSeenData {
[hash: string]: { timestamp: string }
}
function useLastSeen(projectRef: string): LastSeenData {
const now = useRef(new Date()).current
const query = useLogsQuery(projectRef, {
iso_timestamp_start: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(),
iso_timestamp_end: now.toISOString(),
sql: "-- last-used-secret-api-keys\nSELECT unix_millis(max(timestamp)) as timestamp, apikey.`hash` FROM edge_logs cross join unnest(metadata) as m cross join unnest(m.request) as request cross join unnest(request.sb) as sb cross join unnest(sb.apikey) as sbapikey cross join unnest(sbapikey.apikey) as apikey WHERE apikey.error is null and apikey.`hash` is not null and apikey.prefix like 'sb_secret_%' GROUP BY apikey.`hash`",
})
return useMemo(() => {
if (query.isLoading || !query.logData) {
return {}
}
const now = dayjs()
return (query.logData as unknown as { timestamp: number; hash: string }[]).reduce((a, i) => {
a[i.hash] = {
timestamp: `${dayjs.duration(now.diff(dayjs(i.timestamp))).humanize(false)} ago`,
}
return a
}, {} as LastSeenData)
}, [query])
}
export const SecretAPIKeys = () => {
const { ref: projectRef } = useParams()
const {
@@ -28,6 +65,8 @@ export const SecretAPIKeys = () => {
const isLoadingPermissions = !usePermissionsLoaded()
const canReadAPIKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, '*')
const lastSeen = useLastSeen(projectRef!)
const secretApiKeys = useMemo(
() =>
apiKeysData?.filter(
@@ -46,6 +85,9 @@ export const SecretAPIKeys = () => {
<TableCell>
<Skeleton className="max-w-60 h-4 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="max-w-60 h-4 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="w-2 h-4 rounded-full" />
</TableCell>
@@ -53,7 +95,7 @@ export const SecretAPIKeys = () => {
)
const TableContainer = ({ children }: { children: React.ReactNode }) => (
<div>
<div className="pb-30">
<FormHeader
title="Secret keys"
description="These API keys allow privileged access to your project's APIs. Use in servers, functions, workers or other backend components of your application."
@@ -64,25 +106,19 @@ export const SecretAPIKeys = () => {
<Table className="p-5 table-auto">
<TableHeader>
<TableRow className={cn('bg-200', empty && 'hidden')}>
<TableHead
key=""
className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2"
>
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
Name
</TableHead>
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2 pr-0">
API Key
</TableHead>
<TableHead
key=""
className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2"
>
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
Description
</TableHead>
<TableHead
className="text-right font-mono uppercase text-xs text-foreground-lighter h-auto py-2"
key="actions"
/>
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
Last Seen
</TableHead>
<TableHead className="text-right font-mono uppercase text-xs text-foreground-lighter h-auto py-2" />
</TableRow>
</TableHeader>
<TableBody className="">{children}</TableBody>
@@ -145,7 +181,7 @@ export const SecretAPIKeys = () => {
return (
<TableContainer>
{secretApiKeys.map((apiKey) => (
<APIKeyRow key={apiKey.id} apiKey={apiKey} />
<APIKeyRow key={apiKey.id} apiKey={apiKey} lastSeen={lastSeen[apiKey.hash]} />
))}
</TableContainer>
)
@@ -75,7 +75,7 @@ export const useAPIKeyCreateMutation = ({
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to mutate: ${data.message}`)
toast.error(`Failed to create API key: ${data.message}`)
} else {
onError(data, variables, context)
}
@@ -47,7 +47,7 @@ export const useAPIKeyDeleteMutation = ({
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to mutate: ${data.message}`)
toast.error(`Failed to delete API key: ${data.message}`)
} else {
onError(data, variables, context)
}