| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import dayjs from 'dayjs'
- import { ExternalLink } from 'lucide-react'
- import Link from 'next/link'
- import { useCallback, useState } from 'react'
- import { useForm, type SubmitHandler } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Button,
- Form,
- ScrollArea,
- Separator,
- Sheet,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
- } from 'ui'
- import { Admonition } from 'ui-patterns'
- import {
- CUSTOM_EXPIRY_VALUE,
- EXPIRES_AT_OPTIONS,
- type ScopedAccessTokenPermission,
- } from '../AccessToken.constants'
- import { TokenSchema, type TokenFormValues } from '../AccessToken.schemas'
- import { getExpirationDate, mapPermissionToFGA } from '../AccessToken.utils'
- import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData'
- import { BasicInfo } from './Form/BasicInfo'
- import { Permissions } from './Form/Permissions/Permissions'
- import { ResourceAccess } from './Form/ResourceAccess/ResourceAccess'
- import {
- useAccessTokenCreateMutation,
- type NewScopedAccessToken,
- type ScopedAccessTokenCreateVariables,
- } from '@/data/scoped-access-tokens/scoped-access-token-create-mutation'
- import { useTrack } from '@/lib/telemetry/track'
- export interface NewScopedTokenSheetProps {
- visible: boolean
- onOpenChange: (open: boolean) => void
- tokenScope: 'V0' | undefined
- onCreateToken: (token: NewScopedAccessToken) => void
- }
- export const NewScopedTokenSheet = ({
- visible,
- onOpenChange,
- tokenScope,
- onCreateToken,
- }: NewScopedTokenSheetProps) => {
- const [resourceSearchOpen, setResourceSearchOpen] = useState(false)
- const { organizations, projects } = useOrgAndProjectData()
- const form = useForm<TokenFormValues>({
- resolver: zodResolver(TokenSchema as any),
- defaultValues: {
- tokenName: '',
- expiresAt: EXPIRES_AT_OPTIONS['month'].value,
- customExpiryDate: undefined,
- resourceAccess: 'all-orgs',
- selectedOrganizations: [],
- selectedProjects: [],
- permissionRows: [],
- },
- mode: 'onChange',
- })
- const track = useTrack()
- const { mutate: createAccessToken, isPending } = useAccessTokenCreateMutation()
- const resourceAccess = form.watch('resourceAccess')
- const expiresAt = form.watch('expiresAt')
- const permissionRows = form.watch('permissionRows') || []
- const onSubmit: SubmitHandler<TokenFormValues> = async (values) => {
- if (!permissionRows || permissionRows.length === 0) {
- toast.error('Please configure at least one permission.')
- return
- }
- const hasValidPermissions = permissionRows.every(
- (row) => row.resource && row.actions && row.actions.length > 0
- )
- if (!hasValidPermissions) {
- toast.error('Please ensure all permissions have both resource and action selected.')
- return
- }
- if (values.resourceAccess === 'selected-orgs') {
- const selectedOrgs = values.selectedOrganizations || []
- if (selectedOrgs.length === 0) {
- toast.error('Please select at least one organization.')
- return
- }
- const availableOrgSlugs = organizations.map((org) => org.slug)
- const invalidOrgs = selectedOrgs.filter((slug) => !availableOrgSlugs.includes(slug))
- if (invalidOrgs.length > 0) {
- toast.error(
- `You don't have access to the following organization(s): ${invalidOrgs.join(', ')}`
- )
- return
- }
- }
- if (values.resourceAccess === 'selected-projects') {
- const selectedProjects = values.selectedProjects || []
- if (selectedProjects.length === 0) {
- toast.error('Please select at least one project.')
- return
- }
- const availableProjectRefs = projects.map((project) => project.ref)
- const invalidProjects = selectedProjects.filter((ref) => !availableProjectRefs.includes(ref))
- if (invalidProjects.length > 0) {
- toast.error(
- `You don't have access to the following project(s): ${invalidProjects.join(', ')}`
- )
- return
- }
- }
- const finalExpiresAt =
- values.expiresAt === CUSTOM_EXPIRY_VALUE
- ? values.customExpiryDate
- : getExpirationDate(values.expiresAt || '')
- const permissions = permissionRows
- .flatMap((row) => {
- const { resource, actions } = row
- return actions.flatMap((action) => mapPermissionToFGA(resource, action))
- })
- .filter(Boolean) as ScopedAccessTokenPermission[]
- if (!permissions || permissions.length === 0) {
- toast.error('Please configure at least one valid permission.')
- return
- }
- const finalPayload: ScopedAccessTokenCreateVariables = {
- name: values.tokenName,
- permissions,
- }
- if (finalExpiresAt) {
- finalPayload.expires_at = finalExpiresAt
- }
- if (
- values.resourceAccess === 'selected-orgs' &&
- values.selectedOrganizations &&
- values.selectedOrganizations.length > 0
- ) {
- finalPayload.organization_slugs = values.selectedOrganizations
- } else if (
- values.resourceAccess === 'selected-projects' &&
- values.selectedProjects &&
- values.selectedProjects.length > 0
- ) {
- finalPayload.project_refs = values.selectedProjects
- }
- if (!finalPayload.name || finalPayload.name.trim() === '') {
- toast.error('Please enter a token name.')
- return
- }
- if (!finalPayload.permissions || finalPayload.permissions.length === 0) {
- toast.error('Please configure at least one permission.')
- return
- }
- createAccessToken(finalPayload, {
- onSuccess: (data) => {
- track('access_token_created', {
- tokenType: 'scoped',
- expiryPreset: values.expiresAt || 'never',
- resourceAccess: values.resourceAccess,
- permissionCount: permissions.length,
- })
- toast.success('Access token created successfully')
- onCreateToken(data)
- handleClose()
- },
- onError: (error) => {
- if (error.message && error.message.includes("don't have access")) {
- toast.error(
- `Access Error: ${error.message}. Please verify you have access to the selected resources.`
- )
- } else {
- toast.error(`Failed to create access token: ${error.message}`)
- }
- },
- })
- }
- const handleClose = () => {
- form.reset({
- tokenName: '',
- expiresAt: EXPIRES_AT_OPTIONS['month'].value,
- customExpiryDate: undefined,
- resourceAccess: 'all-orgs',
- selectedOrganizations: [],
- selectedProjects: [],
- permissionRows: [],
- })
- onOpenChange(false)
- }
- const handleCustomDateChange = useCallback(
- (date: { date: string } | undefined) => {
- form.setValue('customExpiryDate', date?.date, { shouldValidate: true })
- },
- [form]
- )
- const handleCustomExpiryChange = useCallback(
- (isCustom: boolean) => {
- if (isCustom && !form.getValues('customExpiryDate')) {
- form.setValue('customExpiryDate', dayjs().endOf('day').toISOString(), {
- shouldValidate: true,
- })
- }
- if (!isCustom) {
- form.setValue('customExpiryDate', undefined, { shouldValidate: true })
- }
- },
- [form]
- )
- return (
- <Sheet
- open={visible}
- onOpenChange={(open) => {
- if (!open) {
- handleClose()
- } else {
- onOpenChange(open)
- }
- }}
- >
- <SheetContent
- showClose={false}
- size="default"
- className="min-w-[600px]! flex flex-col h-full gap-0"
- >
- <SheetHeader>
- <SheetTitle>
- {tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
- </SheetTitle>
- <SheetDescription className="sr-only">
- A form to generate a new scoped access token.
- </SheetDescription>
- </SheetHeader>
- <ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
- <div className="flex flex-col overflow-visible">
- {tokenScope === 'V0' && (
- <div className="px-4 sm:px-5 py-4 pb-4">
- <Admonition
- type="warning"
- title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
- description={
- <>
- <p>
- These include deleting organizations and projects which cannot be undone. As
- such, be very careful when using this API.
- </p>
- <div className="mt-4">
- <Button asChild type="default" icon={<ExternalLink />}>
- <Link
- href="https://api.supabase.com/api/v0"
- target="_blank"
- rel="noreferrer"
- >
- Experimental API documentation
- </Link>
- </Button>
- </div>
- </>
- }
- />
- </div>
- )}
- <Form {...form}>
- <div className="flex flex-col gap-0 overflow-visible">
- <BasicInfo
- control={form.control}
- expirationDate={expiresAt || ''}
- onCustomDateChange={handleCustomDateChange}
- onCustomExpiryChange={handleCustomExpiryChange}
- />
- <Separator />
- <ResourceAccess
- control={form.control}
- resourceAccess={resourceAccess}
- setValue={form.setValue}
- />
- <Separator />
- <Permissions
- setValue={form.setValue}
- watch={form.watch}
- resourceSearchOpen={resourceSearchOpen}
- setResourceSearchOpen={setResourceSearchOpen}
- />
- </div>
- </Form>
- </div>
- </ScrollArea>
- <SheetFooter className="justify-end! w-full mt-auto py-4 border-t">
- <div className="flex gap-2">
- <Button type="default" disabled={isPending} onClick={handleClose}>
- Cancel
- </Button>
- <Button onClick={form.handleSubmit(onSubmit)} loading={isPending}>
- Generate token
- </Button>
- </div>
- </SheetFooter>
- </SheetContent>
- </Sheet>
- )
- }
|