SecretsManagement.tsx206 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import { sortBy } from 'lodash'
4import { RefreshCw, Search, X } from 'lucide-react'
5import { parseAsBoolean, useQueryState } from 'nuqs'
6import { useEffect, useMemo, useState } from 'react'
7import DataGrid, { Row } from 'react-data-grid'
8import {
9 Button,
10 cn,
11 LoadingLine,
12 Select,
13 SelectContent,
14 SelectItem,
15 SelectTrigger,
16 SelectValue,
17} from 'ui'
18import { Input } from 'ui-patterns/DataInputs/Input'
19
20import { AddNewSecretModal } from './AddNewSecretModal'
21import { DeleteSecretModal } from './DeleteSecretModal'
22import { EditSecretModal } from './EditSecretModal'
23import { formatSecretColumns } from './Secrets.utils'
24import AlertError from '@/components/ui/AlertError'
25import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
26import { DocsButton } from '@/components/ui/DocsButton'
27import { useVaultSecretsQuery } from '@/data/vault/vault-secrets-query'
28import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
29import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
30import { DOCS_URL } from '@/lib/constants'
31import type { VaultSecret } from '@/types'
32
33export const SecretsManagement = () => {
34 const { search } = useParams()
35 const { data: project } = useSelectedProjectQuery()
36
37 const [searchValue, setSearchValue] = useState<string>('')
38 const [, setShowAddSecretModal] = useQueryState('new', parseAsBoolean.withDefault(false))
39 const [selectedSort, setSelectedSort] = useState<'updated_at' | 'name'>('updated_at')
40
41 const { can: canManageSecrets } = useAsyncCheckPermissions(
42 PermissionAction.TENANT_SQL_ADMIN_WRITE,
43 'tables'
44 )
45
46 const {
47 data,
48 error,
49 isError,
50 isPending: isLoading,
51 isRefetching,
52 refetch,
53 } = useVaultSecretsQuery({
54 projectRef: project?.ref,
55 connectionString: project?.connectionString,
56 })
57 const allSecrets = useMemo(() => data || [], [data])
58
59 const secrets = useMemo(() => {
60 const filtered =
61 searchValue.length > 0
62 ? allSecrets.filter(
63 (secret) =>
64 (secret?.name ?? '').toLowerCase().includes(searchValue.trim().toLowerCase()) ||
65 (secret?.id ?? '').toLowerCase().includes(searchValue.trim().toLowerCase())
66 )
67 : allSecrets
68
69 if (selectedSort === 'updated_at') {
70 return sortBy(filtered, (s) => Number(new Date(s.updated_at))).reverse()
71 }
72 return sortBy(filtered, (s) => (s.name || '').toLowerCase())
73 }, [allSecrets, searchValue, selectedSort])
74
75 const columns = useMemo(() => formatSecretColumns(), [])
76
77 useEffect(() => {
78 if (search !== undefined) setSearchValue(search)
79 }, [search])
80
81 return (
82 <>
83 <div className="h-full w-full space-y-4">
84 <div className="h-full w-full flex flex-col relative">
85 <div className="bg-surface-200 py-3 px-10 flex items-center justify-between flex-wrap">
86 <div className="flex items-center gap-2">
87 <Input
88 size="tiny"
89 className="w-52"
90 placeholder="Search by name or key ID"
91 icon={<Search />}
92 value={searchValue ?? ''}
93 onChange={(e) => setSearchValue(e.target.value)}
94 actions={[
95 searchValue && (
96 <Button
97 key="clear"
98 size="tiny"
99 type="text"
100 icon={<X />}
101 onClick={() => setSearchValue('')}
102 className="p-0 h-5 w-5"
103 />
104 ),
105 ]}
106 />
107
108 <Select value={selectedSort} onValueChange={(v) => setSelectedSort(v as any)}>
109 <SelectTrigger size="tiny" className="w-44">
110 <SelectValue asChild>
111 <>Sort by {selectedSort}</>
112 </SelectValue>
113 </SelectTrigger>
114 <SelectContent>
115 <SelectItem value="updated_at" className="text-xs">
116 Updated at
117 </SelectItem>
118 <SelectItem value="name" className="text-xs">
119 Name
120 </SelectItem>
121 </SelectContent>
122 </Select>
123 </div>
124
125 <div className="flex items-center gap-x-2">
126 <Button
127 type="default"
128 icon={<RefreshCw />}
129 loading={isRefetching}
130 onClick={() => refetch()}
131 >
132 Refresh
133 </Button>
134 <DocsButton href={`${DOCS_URL}/guides/database/vault`} />
135 <ButtonTooltip
136 type="primary"
137 disabled={!canManageSecrets}
138 onClick={() => setShowAddSecretModal(true)}
139 tooltip={{
140 content: {
141 side: 'bottom',
142 text: !canManageSecrets
143 ? 'You need additional permissions to add secrets'
144 : undefined,
145 },
146 }}
147 >
148 Add new secret
149 </ButtonTooltip>
150 </div>
151 </div>
152
153 <LoadingLine loading={isLoading || isRefetching} />
154
155 {isError ? (
156 <div className="grow p-4">
157 <AlertError error={error} subject="Failed to load secrets" />
158 </div>
159 ) : (
160 <DataGrid
161 className="grow border-t-0"
162 rowHeight={52}
163 headerRowHeight={36}
164 columns={columns}
165 rows={secrets}
166 rowKeyGetter={(row: VaultSecret) => row.id}
167 rowClass={() => {
168 return cn(
169 'cursor-pointer',
170 '[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-hidden [&>.rdg-cell]:shadow-none',
171 '[&>.rdg-cell:first-child>div]:pl-8'
172 )
173 }}
174 renderers={{
175 renderRow(_, props) {
176 return <Row key={(props.row as VaultSecret).id} {...props} />
177 },
178 }}
179 />
180 )}
181
182 {secrets.length === 0 && !isLoading && !isError ? (
183 <div className="absolute top-32 px-6 w-full">
184 <div className="text-center text-sm flex flex-col gap-y-1">
185 <p className="text-foreground">
186 {searchValue ? 'No secrets found' : 'No secrets added yet'}
187 </p>
188 <p className="text-foreground-light">
189 {searchValue
190 ? `There are currently no secrets based on the search "${searchValue}"`
191 : 'The Vault allows you to store sensitive information like API keys'}
192 </p>
193 </div>
194 </div>
195 ) : null}
196 </div>
197 </div>
198
199 <AddNewSecretModal />
200
201 <EditSecretModal />
202
203 <DeleteSecretModal />
204 </>
205 )
206}