SecretAPIKeys.tsx184 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useFlag, useParams } from 'common'
3import dayjs from 'dayjs'
4import { parseAsString, useQueryState } from 'nuqs'
5import { useEffect, useMemo, useRef } from 'react'
6import { toast } from 'sonner'
7import { Card } from 'ui'
8import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
9import {
10 Table,
11 TableBody,
12 TableHead,
13 TableHeader,
14 TableRow,
15} from 'ui/src/components/shadcn/ui/table'
16
17import { APIKeyRow } from './APIKeyRow'
18import { CreateSecretAPIKeyDialog } from './CreateSecretAPIKeyDialog'
19import { AlertError } from '@/components/ui/AlertError'
20import { FormHeader } from '@/components/ui/Forms/FormHeader'
21import { NoPermission } from '@/components/ui/NoPermission'
22import { useAPIKeyDeleteMutation } from '@/data/api-keys/api-key-delete-mutation'
23import type { APIKeysData } from '@/data/api-keys/api-keys-query'
24import { useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
25import { useLogsQuery } from '@/hooks/analytics/useLogsQuery'
26import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
27
28interface LastSeenData {
29 [hash: string]: { timestamp: number; relative: string }
30}
31
32function useLastSeen({ projectRef, enabled }: { projectRef: string; enabled?: boolean }): {
33 data?: LastSeenData
34 isLoading: boolean
35} {
36 const now = useRef(new Date()).current
37
38 const query = useLogsQuery(
39 projectRef,
40 {
41 iso_timestamp_start: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(),
42 iso_timestamp_end: now.toISOString(),
43 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`",
44 },
45 enabled
46 )
47
48 return useMemo(() => {
49 if (query.isLoading || !query.logData) {
50 return { data: undefined, isLoading: query.isLoading }
51 }
52
53 const now = dayjs()
54
55 const lastSeen = (query.logData as unknown as { timestamp: number; hash: string }[]).reduce(
56 (a, i) => {
57 a[i.hash] = {
58 timestamp: i.timestamp,
59 relative: `${dayjs.duration(now.diff(dayjs(i.timestamp))).humanize(false)} ago`,
60 }
61 return a
62 },
63 {} as LastSeenData
64 )
65
66 return { data: lastSeen, isLoading: query.isLoading }
67 }, [query])
68}
69
70export const SecretAPIKeys = () => {
71 const { ref: projectRef } = useParams()
72 const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
73 PermissionAction.SECRETS_READ,
74 '*'
75 )
76
77 const {
78 data: apiKeysData,
79 error,
80 isSuccess: isSuccessApiKeys,
81 isPending: isLoadingApiKeys,
82 isError: isErrorApiKeys,
83 } = useAPIKeysQuery({ projectRef, reveal: false }, { enabled: canReadAPIKeys })
84
85 const showApiKeysLastUsed = useFlag('showApiKeysLastUsed')
86 const { data: lastSeen, isLoading: isLoadingLastSeen } = useLastSeen({
87 projectRef: projectRef ?? '',
88 enabled: showApiKeysLastUsed,
89 })
90
91 const secretApiKeys = useMemo(
92 () =>
93 apiKeysData?.filter(
94 (key): key is Extract<APIKeysData[number], { type: 'secret' }> => key.type === 'secret'
95 ) ?? [],
96 [apiKeysData]
97 )
98
99 const empty = secretApiKeys?.length === 0 && !isLoadingApiKeys && !isLoadingPermissions
100
101 const [deleteId, setDeleteId] = useQueryState('deleteSecretKey', parseAsString)
102 const apiKeyToDelete = secretApiKeys?.find((key) => key.id === deleteId)
103
104 const {
105 mutate: deleteAPIKey,
106 isPending: isDeletingAPIKey,
107 isSuccess: isDeleteSuccess,
108 } = useAPIKeyDeleteMutation({
109 onSuccess: () => {
110 toast.success('Successfully deleted secret key')
111 setDeleteId(null)
112 },
113 })
114
115 const onDeleteAPIKey = (apiKey: Extract<APIKeysData[number], { type: 'secret' }>) => {
116 if (!projectRef) return console.error('Project ref is required')
117 if (!apiKey.id) return console.error('API key ID is required')
118 deleteAPIKey({ projectRef, id: apiKey.id })
119 }
120
121 useEffect(() => {
122 if (isSuccessApiKeys && !!deleteId && !apiKeyToDelete && !isDeleteSuccess) {
123 toast('Unable to find secret key')
124 setDeleteId(null)
125 }
126 }, [apiKeyToDelete, deleteId, isDeleteSuccess, isSuccessApiKeys, setDeleteId])
127
128 return (
129 <div className="pb-30">
130 <FormHeader
131 title="Secret keys"
132 description="These API keys allow privileged access to your project's APIs. Use in servers, functions, workers or other backend components of your application."
133 actions={<CreateSecretAPIKeyDialog />}
134 />
135
136 {!canReadAPIKeys && !isLoadingPermissions ? (
137 <NoPermission resourceText="view API keys" />
138 ) : isLoadingApiKeys || isLoadingPermissions ? (
139 <GenericSkeletonLoader />
140 ) : isErrorApiKeys ? (
141 <AlertError error={error} subject="Failed to load secret API keys" />
142 ) : empty ? (
143 <Card>
144 <div className="rounded-b-md! overflow-hidden py-12 flex flex-col gap-1 items-center justify-center">
145 <p className="text-sm text-foreground">No secret API keys found</p>
146 <p className="text-sm text-foreground-light">
147 Your project is not accessible via secret keys—there are no active secret keys
148 created.
149 </p>
150 </div>
151 </Card>
152 ) : (
153 <Card className="bg-surface-100">
154 <Table>
155 <TableHeader>
156 <TableRow className="bg-200">
157 <TableHead>Name</TableHead>
158 <TableHead>API Key</TableHead>
159 {showApiKeysLastUsed && (
160 <TableHead className="hidden lg:table-cell">Last Used</TableHead>
161 )}
162 <TableHead />
163 </TableRow>
164 </TableHeader>
165 <TableBody>
166 {secretApiKeys.map((apiKey) => (
167 <APIKeyRow
168 key={apiKey.id}
169 apiKey={apiKey}
170 lastSeen={lastSeen?.[apiKey.hash]}
171 isLoadingLastSeen={isLoadingLastSeen}
172 isDeleting={apiKeyToDelete?.id === apiKey.id && isDeletingAPIKey}
173 onDelete={() => onDeleteAPIKey(apiKey)}
174 setKeyToDelete={setDeleteId}
175 isDeleteModalOpen={apiKeyToDelete?.id === apiKey.id}
176 />
177 ))}
178 </TableBody>
179 </Table>
180 </Card>
181 )}
182 </div>
183 )
184}