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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
828
apps/studio/data/api.d.ts
vendored
828
apps/studio/data/api.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
|
||||
47
apps/studio/data/storage/s3-access-key-create-mutation.ts
Normal file
47
apps/studio/data/storage/s3-access-key-create-mutation.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
38
apps/studio/data/storage/s3-access-key-delete-mutation.ts
Normal file
38
apps/studio/data/storage/s3-access-key-delete-mutation.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
4
apps/studio/data/storage/s3-access-key-keys.ts
Normal file
4
apps/studio/data/storage/s3-access-key-keys.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const storageCredentialsKeys = {
|
||||
credentials: (projectRef: string | undefined) =>
|
||||
['projects', projectRef, 'storage-credentials'] as const,
|
||||
}
|
||||
37
apps/studio/data/storage/s3-access-key-query.ts
Normal file
37
apps/studio/data/storage/s3-access-key-query.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user