S3Connection.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { AlertTitle } from '@ui/components/shadcn/ui/alert'
  4. import { useParams } from 'common'
  5. import Link from 'next/link'
  6. import { useEffect, useState } from 'react'
  7. import { SubmitHandler, useForm } from 'react-hook-form'
  8. import { toast } from 'sonner'
  9. import {
  10. Alert,
  11. AlertDescription,
  12. Button,
  13. Card,
  14. CardContent,
  15. CardFooter,
  16. Form,
  17. FormControl,
  18. FormField,
  19. Switch,
  20. Table,
  21. TableBody,
  22. TableCell,
  23. TableHead,
  24. TableHeader,
  25. TableRow,
  26. WarningIcon,
  27. } from 'ui'
  28. import { Input } from 'ui-patterns/DataInputs/Input'
  29. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  30. import { PageContainer } from 'ui-patterns/PageContainer'
  31. import {
  32. PageSection,
  33. PageSectionAside,
  34. PageSectionContent,
  35. PageSectionDescription,
  36. PageSectionMeta,
  37. PageSectionSummary,
  38. PageSectionTitle,
  39. } from 'ui-patterns/PageSection'
  40. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  41. import * as z from 'zod'
  42. import { CreateCredentialModal } from './CreateCredentialModal'
  43. import { RevokeCredentialModal } from './RevokeCredentialModal'
  44. import { StorageCredItem } from './StorageCredItem'
  45. import { getConnectionURL } from './StorageSettings.utils'
  46. import AlertError from '@/components/ui/AlertError'
  47. import { DocsButton } from '@/components/ui/DocsButton'
  48. import NoPermission from '@/components/ui/NoPermission'
  49. import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
  50. import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query'
  51. import { useProjectStorageConfigUpdateUpdateMutation } from '@/data/config/project-storage-config-update-mutation'
  52. import { useStorageCredentialsQuery } from '@/data/storage/s3-access-key-query'
  53. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  54. import { useIsProjectActive, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  55. import { DOCS_URL } from '@/lib/constants'
  56. export const S3Connection = () => {
  57. const { ref: projectRef } = useParams()
  58. const isProjectActive = useIsProjectActive()
  59. const { data: project, isPending: projectIsLoading } = useSelectedProjectQuery()
  60. const [openCreateCred, setOpenCreateCred] = useState(false)
  61. const [openDeleteDialog, setOpenDeleteDialog] = useState(false)
  62. const [deleteCred, setDeleteCred] = useState<{ id: string; description: string }>()
  63. const { can: canReadS3Credentials, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
  64. PermissionAction.STORAGE_ADMIN_READ,
  65. '*'
  66. )
  67. const { can: canUpdateStorageSettings } = useAsyncCheckPermissions(
  68. PermissionAction.STORAGE_ADMIN_WRITE,
  69. '*'
  70. )
  71. const { data: settings } = useProjectSettingsV2Query({ projectRef })
  72. const {
  73. data: config,
  74. error: configError,
  75. isSuccess: isSuccessStorageConfig,
  76. isError: isErrorStorageConfig,
  77. } = useProjectStorageConfigQuery({ projectRef })
  78. const { data: storageCreds, isPending: isLoadingStorageCreds } = useStorageCredentialsQuery(
  79. { projectRef },
  80. { enabled: canReadS3Credentials }
  81. )
  82. const { mutate: updateStorageConfig, isPending: isUpdating } =
  83. useProjectStorageConfigUpdateUpdateMutation({
  84. onSuccess: (_, vars) => {
  85. if (vars.features?.s3Protocol) {
  86. form.reset({ s3ConnectionEnabled: vars.features.s3Protocol.enabled })
  87. }
  88. toast.success('Successfully updated storage settings')
  89. },
  90. })
  91. const FormSchema = z.object({ s3ConnectionEnabled: z.boolean() })
  92. const form = useForm<z.infer<typeof FormSchema>>({
  93. resolver: zodResolver(FormSchema as any),
  94. defaultValues: { s3ConnectionEnabled: false },
  95. })
  96. const protocol = settings?.app_config?.protocol ?? 'https'
  97. const endpoint = settings?.app_config?.storage_endpoint || settings?.app_config?.endpoint
  98. const hasStorageCreds = storageCreds?.data && storageCreds.data.length > 0
  99. const s3connectionUrl = getConnectionURL(projectRef ?? '', protocol, endpoint)
  100. const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (data) => {
  101. if (!projectRef) return console.error('Project ref is required')
  102. if (!config) return console.error('Storage config is required')
  103. updateStorageConfig({
  104. projectRef,
  105. features: {
  106. ...config?.features,
  107. s3Protocol: { enabled: data.s3ConnectionEnabled },
  108. },
  109. })
  110. }
  111. useEffect(() => {
  112. form.reset({ s3ConnectionEnabled: config?.features.s3Protocol.enabled })
  113. // eslint-disable-next-line react-hooks/exhaustive-deps
  114. }, [isSuccessStorageConfig])
  115. return (
  116. <>
  117. <PageContainer>
  118. <PageSection>
  119. <PageSectionMeta>
  120. <PageSectionSummary>
  121. <PageSectionTitle>Connection</PageSectionTitle>
  122. <PageSectionDescription>
  123. Connect to your bucket using any S3-compatible service via the S3 protocol
  124. </PageSectionDescription>
  125. </PageSectionSummary>
  126. <PageSectionAside>
  127. <DocsButton href={`${DOCS_URL}/guides/storage/s3/authentication`} />
  128. </PageSectionAside>
  129. </PageSectionMeta>
  130. <PageSectionContent>
  131. {isErrorStorageConfig && (
  132. <AlertError
  133. className="mb-4"
  134. subject="Failed to retrieve storage configuration"
  135. error={configError}
  136. />
  137. )}
  138. <Form {...form}>
  139. <form id="s3-connection-form" onSubmit={form.handleSubmit(onSubmit)}>
  140. {projectIsLoading ? (
  141. <GenericSkeletonLoader />
  142. ) : isProjectActive ? (
  143. <Card>
  144. <CardContent>
  145. <FormField
  146. name="s3ConnectionEnabled"
  147. control={form.control}
  148. render={({ field }) => (
  149. <FormItemLayout
  150. layout="flex-row-reverse"
  151. className="[&>*>label]:text-foreground"
  152. label="S3 protocol connection"
  153. description="Allow clients to connect to Briven Storage via the S3 protocol"
  154. >
  155. <FormControl>
  156. <Switch
  157. size="large"
  158. checked={field.value}
  159. onCheckedChange={field.onChange}
  160. disabled={!isSuccessStorageConfig || field.disabled}
  161. />
  162. </FormControl>
  163. </FormItemLayout>
  164. )}
  165. />
  166. </CardContent>
  167. <CardContent>
  168. <FormItemLayout
  169. layout="flex-row-reverse"
  170. className="[&>div]:md:w-1/2 [&>div>div]:w-full [&>div]:min-w-100"
  171. label="Endpoint"
  172. isReactForm={false}
  173. >
  174. <Input readOnly copy value={s3connectionUrl} />
  175. </FormItemLayout>
  176. </CardContent>
  177. <CardContent>
  178. <FormItemLayout
  179. layout="flex-row-reverse"
  180. className="[&>div]:md:w-1/2 [&>div>div]:w-full [&>div]:min-w-100"
  181. label="Region"
  182. isReactForm={false}
  183. >
  184. <Input
  185. readOnly
  186. copy
  187. value={project?.region}
  188. data-1p-ignore
  189. data-lpignore="true"
  190. data-form-type="other"
  191. data-bwignore
  192. />
  193. </FormItemLayout>
  194. </CardContent>
  195. {!isLoadingPermissions && !canUpdateStorageSettings && (
  196. <CardContent>
  197. <p className="text-sm text-foreground-light">
  198. You need additional permissions to update storage settings
  199. </p>
  200. </CardContent>
  201. )}
  202. <CardFooter className="justify-end space-x-2">
  203. {form.formState.isDirty && (
  204. <Button
  205. type="default"
  206. htmlType="reset"
  207. onClick={() => form.reset()}
  208. disabled={
  209. !form.formState.isDirty || !canUpdateStorageSettings || isUpdating
  210. }
  211. >
  212. Cancel
  213. </Button>
  214. )}
  215. <Button
  216. type="primary"
  217. htmlType="submit"
  218. loading={isUpdating}
  219. disabled={
  220. !form.formState.isDirty || !canUpdateStorageSettings || isUpdating
  221. }
  222. >
  223. Save
  224. </Button>
  225. </CardFooter>
  226. </Card>
  227. ) : (
  228. <Alert variant="warning">
  229. <WarningIcon />
  230. <AlertTitle>Project is paused</AlertTitle>
  231. <AlertDescription>
  232. To connect to your S3 bucket, you need to restore your project.
  233. </AlertDescription>
  234. <div className="mt-3 flex items-center space-x-2">
  235. <Button asChild type="default">
  236. <Link href={`/project/${projectRef}`}>Restore project</Link>
  237. </Button>
  238. </div>
  239. </Alert>
  240. )}
  241. </form>
  242. </Form>
  243. </PageSectionContent>
  244. </PageSection>
  245. <PageSection>
  246. <PageSectionMeta>
  247. <PageSectionSummary>
  248. <PageSectionTitle>Access keys</PageSectionTitle>
  249. <PageSectionDescription>
  250. Manage your access keys for this project
  251. </PageSectionDescription>
  252. </PageSectionSummary>
  253. <PageSectionAside>
  254. <CreateCredentialModal visible={openCreateCred} onOpenChange={setOpenCreateCred} />
  255. </PageSectionAside>
  256. </PageSectionMeta>
  257. <PageSectionContent>
  258. {projectIsLoading || isLoadingPermissions ? (
  259. <GenericSkeletonLoader />
  260. ) : !canReadS3Credentials ? (
  261. <NoPermission resourceText="view this project's S3 access keys" />
  262. ) : !isProjectActive ? (
  263. <Alert variant="warning">
  264. <WarningIcon />
  265. <AlertTitle>Can't fetch S3 access keys</AlertTitle>
  266. <AlertDescription>
  267. To fetch your S3 access keys, you need to restore your project.
  268. </AlertDescription>
  269. <AlertDescription>
  270. <Button asChild type="default" className="mt-3">
  271. <Link href={`/project/${projectRef}`}>Restore project</Link>
  272. </Button>
  273. </AlertDescription>
  274. </Alert>
  275. ) : (
  276. <>
  277. {isLoadingStorageCreds ? (
  278. <GenericSkeletonLoader />
  279. ) : (
  280. <Card>
  281. <Table>
  282. <TableHeader>
  283. <TableRow>
  284. <TableHead key="description">Name</TableHead>
  285. <TableHead key="access-key-id">Key ID</TableHead>
  286. <TableHead key="created-at">Created at</TableHead>
  287. <TableHead key="actions" />
  288. </TableRow>
  289. </TableHeader>
  290. <TableBody>
  291. {hasStorageCreds ? (
  292. storageCreds.data?.map((cred) => (
  293. <StorageCredItem
  294. key={cred.id}
  295. created_at={cred.created_at}
  296. access_key={cred.access_key}
  297. description={cred.description}
  298. id={cred.id}
  299. onDeleteClick={() => {
  300. setDeleteCred(cred)
  301. setOpenDeleteDialog(true)
  302. }}
  303. />
  304. ))
  305. ) : (
  306. <TableRow>
  307. <TableCell colSpan={4} className="rounded-b-md! overflow-hidden">
  308. <p className="text-sm text-foreground">No access keys created</p>
  309. <p className="text-sm text-foreground-light">
  310. There are no access keys associated with your project yet
  311. </p>
  312. </TableCell>
  313. </TableRow>
  314. )}
  315. </TableBody>
  316. </Table>
  317. </Card>
  318. )}
  319. </>
  320. )}
  321. </PageSectionContent>
  322. </PageSection>
  323. </PageContainer>
  324. <RevokeCredentialModal
  325. visible={openDeleteDialog}
  326. selectedCredential={deleteCred}
  327. onClose={() => {
  328. setOpenDeleteDialog(false)
  329. setDeleteCred(undefined)
  330. }}
  331. />
  332. </>
  333. )
  334. }