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