SecretsManagement.tsx 6.9 KB

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