feat: add docs and creds management for storage connections (#22620)

* add docs, and creds management

* FIx api types.

* add accesskey to table

* cmt

* fix issues, url, styles, rm unused mutation keys

* Apply suggestions from code review

Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>

* renaming of things and use correct compos

* Update apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx

Co-authored-by: Inian <inian1234@gmail.com>

* rename storage url to endpoint

* when a user clicks the X after creating a credential, reset the form

* Fix button component disabled state when loading is true, and add docs url to s3 connection section

* Fixes

* fix btn disabled prop not reaching btn

---------

Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
Co-authored-by: Inian <inian1234@gmail.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Jordi Enric
2024-04-15 19:32:06 +02:00
committed by GitHub
parent afc83bb2f8
commit f569a46528
10 changed files with 1292 additions and 39 deletions

View File

@@ -0,0 +1,343 @@
import { differenceInDays } from 'date-fns'
import React from 'react'
import toast from 'react-hot-toast'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import Table from 'components/to-be-cleaned/Table'
import CopyButton from 'components/ui/CopyButton'
import { FormHeader } from 'components/ui/Forms'
import Panel from 'components/ui/Panel'
import { useProjectApiQuery } from 'data/config/project-api-query'
import { useS3AccessKeyCreateMutation } from 'data/storage/s3-access-key-create-mutation'
import { useS3AccessKeyDeleteMutation } from 'data/storage/s3-access-key-delete-mutation'
import { useStorageCredentialsQuery } from 'data/storage/s3-access-key-query'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconMoreVertical,
IconTrash,
cn,
} from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
export const S3Connection = () => {
const [openCreateCred, setOpenCreateCred] = React.useState(false)
const [showSuccess, setShowSuccess] = React.useState(false)
const [openDeleteDialog, setOpenDeleteDialog] = React.useState(false)
const [deleteCredId, setDeleteCredId] = React.useState<string | null>(null)
const { ref: projectRef } = useParams()
const { project, isLoading: projectIsLoading } = useProjectContext()
const { data: storageCreds, ...storageCredsQuery } = useStorageCredentialsQuery({
projectRef,
})
const hasStorageCreds = storageCreds?.data && storageCreds.data.length > 0
const createS3AccessKey = useS3AccessKeyCreateMutation({
projectRef,
})
const deleteS3AccessKey = useS3AccessKeyDeleteMutation({
projectRef,
})
const { data: projectAPI } = useProjectApiQuery({ projectRef: projectRef })
function getConnectionURL() {
const projUrl = projectAPI
? `${projectAPI.autoApiService.protocol}://${projectAPI.autoApiService.endpoint}`
: `https://${projectRef}.supabase.co`
const url = new URL(projUrl)
url.pathname = '/storage/v1/s3'
return url.toString()
}
const s3connectionUrl = getConnectionURL()
return (
<>
<div>
<FormHeader
title="S3 Connection"
description="Connect to your bucket via the S3 protocol."
docsUrl="https://supabase.com/docs/guides/storage/s3/authentication"
/>
<Panel className="grid gap-4 p-4 !mb-0">
<FormItemLayout layout="horizontal" label="Endpoint" isReactForm={false}>
<Input readOnly copy disabled value={s3connectionUrl} />
</FormItemLayout>
{projectIsLoading ? (
<></>
) : (
<FormItemLayout layout="horizontal" label="Region" isReactForm={false}>
<Input className="input-mono" copy disabled value={project?.region} />
</FormItemLayout>
)}
</Panel>
</div>
<div>
<FormHeader
title="S3 Credentials"
description="Manage your S3 credentials for this project."
actions={
<Dialog
open={openCreateCred}
onOpenChange={(open) => {
setOpenCreateCred(open)
if (!open) setShowSuccess(false)
}}
>
<DialogTrigger asChild>
<Button type="outline">New credential</Button>
</DialogTrigger>
<DialogContent
className="p-4"
onInteractOutside={(e) => {
if (showSuccess) {
e.preventDefault()
}
}}
>
{showSuccess ? (
<>
<DialogTitle>Save your new S3 access keys</DialogTitle>
<DialogDescription>
You won't be able to see them again. If you lose these credentials, you'll
need to create a new ones.
</DialogDescription>
<FormItemLayout label="Access key id" isReactForm={false}>
<Input
className="input-mono"
readOnly
copy
disabled
value={createS3AccessKey.data?.data?.access_key}
/>
</FormItemLayout>
<FormItemLayout label={'Secret access key'} isReactForm={false}>
<Input
className="input-mono"
readOnly
copy
disabled
value={createS3AccessKey.data?.data?.secret_key}
/>
</FormItemLayout>
<div className="flex justify-end">
<Button
className="mt-4"
onClick={() => {
setOpenCreateCred(false)
setShowSuccess(false)
}}
>
Done
</Button>
</div>
</>
) : (
<>
<DialogTitle>Create new S3 access keys</DialogTitle>
<form
onSubmit={async (e) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const description = formData.get('description') as string
await createS3AccessKey.mutateAsync({ description })
setShowSuccess(true)
}}
>
<FormItemLayout label="Description" isReactForm={false}>
<Input
autoComplete="off"
placeholder="My test key"
type="text"
name="description"
required
/>
</FormItemLayout>
<div className="flex justify-end">
<Button
className="mt-4"
htmlType="submit"
loading={createS3AccessKey.isLoading}
>
Create credential
</Button>
</div>
</form>
</>
)}
</DialogContent>
</Dialog>
}
/>
<div
className={cn([
'bg-surface-100',
'overflow-hidden border-muted',
'rounded-md border shadow',
])}
>
{storageCredsQuery.isLoading ? (
<div className="p-4">
<GenericSkeletonLoader />
</div>
) : (
<div className="overflow-x-auto">
<Table
head={[
<Table.th key="">Description</Table.th>,
<Table.th key="">Access key id</Table.th>,
<Table.th key="">Created at</Table.th>,
<Table.th key="actions" />,
]}
body={
hasStorageCreds ? (
storageCreds.data?.map((cred: any) => (
<StorageCredItem
key={cred.id}
created_at={cred.created_at}
access_key={cred.access_key}
description={cred.description}
id={cred.id}
onDeleteClick={() => {
setDeleteCredId(cred.id)
setOpenDeleteDialog(true)
}}
/>
))
) : (
<Table.tr>
<Table.td colSpan={4}>
<p className="text-sm text-foreground">No credentials created</p>
<p className="text-sm text-foreground-light">
There are no credentials associated with your project yet
</p>
</Table.td>
</Table.tr>
)
}
/>
</div>
)}
</div>
</div>
<Dialog open={openDeleteDialog} onOpenChange={setOpenDeleteDialog}>
<DialogContent className="p-4">
<DialogTitle>Revoke S3 access keys</DialogTitle>
<DialogDescription>
This action is irreversible and requests made with these credentials will stop working.
</DialogDescription>
<div className="flex justify-end gap-2">
<Button
type="outline"
onClick={() => {
setOpenDeleteDialog(false)
setDeleteCredId(null)
}}
>
Cancel
</Button>
<Button
type="danger"
loading={deleteS3AccessKey.isLoading}
onClick={async () => {
if (!deleteCredId) return
await deleteS3AccessKey.mutateAsync({ id: deleteCredId })
setOpenDeleteDialog(false)
toast.success('S3 access keys revoked')
}}
>
Yes, revoke credentials
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}
function StorageCredItem({
description,
id,
created_at,
access_key,
onDeleteClick,
}: {
description: string
id: string
created_at: string
access_key: string
onDeleteClick: (id: string) => void
}) {
function daysSince(date: string) {
const now = new Date()
const created = new Date(date)
const diffInDays = differenceInDays(now, created)
if (diffInDays === 0) {
return 'Today'
} else if (diffInDays === 1) {
return `${diffInDays} day ago`
} else {
return `${diffInDays} days ago`
}
}
return (
<tr className="h-8 text-ellipsis group">
<td>{description}</td>
<td>
<div className="flex items-center justify-between">
<span className="text-ellipsis font-mono cursor-default">{access_key}</span>
<span className="w-24 text-right opacity-0 group-hover:opacity-100 transition-opacity">
<CopyButton text={access_key} type="outline" />
</span>
</div>
</td>
<td>{daysSince(created_at)}</td>
<td>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center justify-end w-full">
<IconMoreVertical />
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-40">
<DropdownMenuItem
className="flex gap-1.5 "
onClick={(e) => {
e.preventDefault()
onDeleteClick(id)
}}
>
<IconTrash />
Revoke credentials
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
)
}

View File

@@ -4,16 +4,19 @@ import ReactMarkdown from 'react-markdown'
import { Markdown } from 'components/interfaces/Markdown'
import { Button } from 'ui'
import { ReactNode } from 'react'
const FormHeader = ({
title,
description,
docsUrl,
actions,
className,
}: {
title: string
description?: string
docsUrl?: string
actions?: ReactNode
className?: string
}) => {
return (
@@ -26,13 +29,16 @@ const FormHeader = ({
</h3>
{description && <Markdown content={description} className="max-w-full" />}
</div>
{docsUrl !== undefined && (
<Button asChild type="default" icon={<ExternalLink size={14} />}>
<Link href={docsUrl} target="_blank" rel="noreferrer">
Documentation
</Link>
</Button>
)}
<div className="flex items-center gap-x-2">
{docsUrl !== undefined && (
<Button asChild type="default" icon={<ExternalLink size={14} />}>
<Link href={docsUrl} target="_blank" rel="noreferrer">
Documentation
</Link>
</Button>
)}
{actions}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -52,7 +52,7 @@ export async function executeSql(
},
body: { query: sql },
headers: Object.fromEntries(headers),
})
} as any) // Needed to fix generated api types for now
if (error) {
if (

View File

@@ -0,0 +1,47 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { storageCredentialsKeys } from './s3-access-key-keys'
import { post } from 'data/fetchers'
type CreateS3AccessKeyCredential = {
description: string
projectRef?: string
}
const createS3AccessKeyCredential = async ({
description,
projectRef,
}: CreateS3AccessKeyCredential) => {
if (!projectRef) {
throw new Error('projectRef is required')
}
const res = await post('/platform/storage/{ref}/credentials', {
params: {
path: {
ref: projectRef,
},
},
body: {
description,
},
})
return res
}
type S3AccessKeyCreateMutation = {
projectRef?: string
}
export function useS3AccessKeyCreateMutation({ projectRef }: S3AccessKeyCreateMutation) {
const queryClient = useQueryClient()
const keys = storageCredentialsKeys.credentials(projectRef)
return useMutation({
mutationFn: ({ description }: { description: string }) =>
createS3AccessKeyCredential({ description, projectRef }),
onSettled: () => {
queryClient.invalidateQueries(keys)
},
})
}

View File

@@ -0,0 +1,38 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { storageCredentialsKeys } from './s3-access-key-keys'
import { del } from 'data/fetchers'
type S3AccessKeyDeleteMutation = {
projectRef?: string
id?: string
}
const deleteS3AccessKeyCredential = async ({ projectRef, id }: S3AccessKeyDeleteMutation) => {
if (!projectRef || !id) {
throw new Error('projectRef and id are required')
}
const res = await del('/platform/storage/{ref}/credentials/{id}', {
params: {
path: {
ref: projectRef,
id,
},
},
})
return res
}
export function useS3AccessKeyDeleteMutation({ projectRef }: S3AccessKeyDeleteMutation) {
const queryClient = useQueryClient()
const keys = storageCredentialsKeys.credentials(projectRef)
return useMutation({
mutationFn: ({ id }: { id: string }) => deleteS3AccessKeyCredential({ projectRef, id }),
onSettled: () => {
queryClient.invalidateQueries(keys)
},
})
}

View File

@@ -0,0 +1,4 @@
export const storageCredentialsKeys = {
credentials: (projectRef: string | undefined) =>
['projects', projectRef, 'storage-credentials'] as const,
}

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query'
import { storageCredentialsKeys } from './s3-access-key-keys'
import { get } from 'data/fetchers'
type FetchStorageCredentials = {
projectRef?: string
}
async function fetchStorageCredentials({ projectRef }: FetchStorageCredentials) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const res = await get('/platform/storage/{ref}/credentials', {
params: {
path: {
ref: projectRef,
},
},
})
return res.data
}
type StorageCredentialsQuery = {
projectRef?: string
}
export function useStorageCredentialsQuery({ projectRef }: StorageCredentialsQuery) {
const keys = storageCredentialsKeys.credentials(projectRef)
const query = useQuery({
queryKey: keys,
queryFn: () => fetchStorageCredentials({ projectRef }),
enabled: projectRef !== undefined,
})
return query
}

View File

@@ -1,11 +1,16 @@
import { SettingsLayout } from 'components/layouts'
import { StorageSettings } from 'components/to-be-cleaned/Storage'
import { S3Connection } from 'components/to-be-cleaned/Storage/StorageSettings/S3Connection'
import { useFlag } from 'hooks'
import type { NextPageWithLayout } from 'types'
const PageLayout: NextPageWithLayout = () => {
const showS3Connection = useFlag('showS3Connection')
return (
<div className="1xl:px-28 mx-auto flex flex-col gap-8 px-5 py-6 lg:px-16 xl:px-24 2xl:px-32">
<div className="1xl:px-28 mx-auto flex flex-col gap-y-10 px-5 py-6 lg:px-16 xl:px-24 2xl:px-32 pb-32">
<StorageSettings />
{showS3Connection && <S3Connection />}
</div>
)
}

View File

@@ -218,12 +218,12 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
ref
) => {
const Comp = asChild ? Slot : 'button'
const { className, disabled } = props
const { className } = props
const showIcon = loading || icon
// decrecating 'showIcon' for rightIcon
const _iconLeft: React.ReactNode = icon ?? iconLeft
// if loading, button is disabled
props.disabled = loading ? true : props.disabled
const disabled = loading === true || props.disabled
return (
<Comp
@@ -231,6 +231,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
data-size={size}
type={htmlType}
{...props}
disabled={disabled}
className={cn(buttonVariants({ type, size, disabled, block, rounded }), className)}
>
{asChild ? (