mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
fix: more api key ui fixes (#36300)
This commit is contained in:
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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user