NewScopedTokenSheet.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import dayjs from 'dayjs'
  3. import { ExternalLink } from 'lucide-react'
  4. import Link from 'next/link'
  5. import { useCallback, useState } from 'react'
  6. import { useForm, type SubmitHandler } from 'react-hook-form'
  7. import { toast } from 'sonner'
  8. import {
  9. Button,
  10. Form,
  11. ScrollArea,
  12. Separator,
  13. Sheet,
  14. SheetContent,
  15. SheetDescription,
  16. SheetFooter,
  17. SheetHeader,
  18. SheetTitle,
  19. } from 'ui'
  20. import { Admonition } from 'ui-patterns'
  21. import {
  22. CUSTOM_EXPIRY_VALUE,
  23. EXPIRES_AT_OPTIONS,
  24. type ScopedAccessTokenPermission,
  25. } from '../AccessToken.constants'
  26. import { TokenSchema, type TokenFormValues } from '../AccessToken.schemas'
  27. import { getExpirationDate, mapPermissionToFGA } from '../AccessToken.utils'
  28. import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData'
  29. import { BasicInfo } from './Form/BasicInfo'
  30. import { Permissions } from './Form/Permissions/Permissions'
  31. import { ResourceAccess } from './Form/ResourceAccess/ResourceAccess'
  32. import {
  33. useAccessTokenCreateMutation,
  34. type NewScopedAccessToken,
  35. type ScopedAccessTokenCreateVariables,
  36. } from '@/data/scoped-access-tokens/scoped-access-token-create-mutation'
  37. import { useTrack } from '@/lib/telemetry/track'
  38. export interface NewScopedTokenSheetProps {
  39. visible: boolean
  40. onOpenChange: (open: boolean) => void
  41. tokenScope: 'V0' | undefined
  42. onCreateToken: (token: NewScopedAccessToken) => void
  43. }
  44. export const NewScopedTokenSheet = ({
  45. visible,
  46. onOpenChange,
  47. tokenScope,
  48. onCreateToken,
  49. }: NewScopedTokenSheetProps) => {
  50. const [resourceSearchOpen, setResourceSearchOpen] = useState(false)
  51. const { organizations, projects } = useOrgAndProjectData()
  52. const form = useForm<TokenFormValues>({
  53. resolver: zodResolver(TokenSchema as any),
  54. defaultValues: {
  55. tokenName: '',
  56. expiresAt: EXPIRES_AT_OPTIONS['month'].value,
  57. customExpiryDate: undefined,
  58. resourceAccess: 'all-orgs',
  59. selectedOrganizations: [],
  60. selectedProjects: [],
  61. permissionRows: [],
  62. },
  63. mode: 'onChange',
  64. })
  65. const track = useTrack()
  66. const { mutate: createAccessToken, isPending } = useAccessTokenCreateMutation()
  67. const resourceAccess = form.watch('resourceAccess')
  68. const expiresAt = form.watch('expiresAt')
  69. const permissionRows = form.watch('permissionRows') || []
  70. const onSubmit: SubmitHandler<TokenFormValues> = async (values) => {
  71. if (!permissionRows || permissionRows.length === 0) {
  72. toast.error('Please configure at least one permission.')
  73. return
  74. }
  75. const hasValidPermissions = permissionRows.every(
  76. (row) => row.resource && row.actions && row.actions.length > 0
  77. )
  78. if (!hasValidPermissions) {
  79. toast.error('Please ensure all permissions have both resource and action selected.')
  80. return
  81. }
  82. if (values.resourceAccess === 'selected-orgs') {
  83. const selectedOrgs = values.selectedOrganizations || []
  84. if (selectedOrgs.length === 0) {
  85. toast.error('Please select at least one organization.')
  86. return
  87. }
  88. const availableOrgSlugs = organizations.map((org) => org.slug)
  89. const invalidOrgs = selectedOrgs.filter((slug) => !availableOrgSlugs.includes(slug))
  90. if (invalidOrgs.length > 0) {
  91. toast.error(
  92. `You don't have access to the following organization(s): ${invalidOrgs.join(', ')}`
  93. )
  94. return
  95. }
  96. }
  97. if (values.resourceAccess === 'selected-projects') {
  98. const selectedProjects = values.selectedProjects || []
  99. if (selectedProjects.length === 0) {
  100. toast.error('Please select at least one project.')
  101. return
  102. }
  103. const availableProjectRefs = projects.map((project) => project.ref)
  104. const invalidProjects = selectedProjects.filter((ref) => !availableProjectRefs.includes(ref))
  105. if (invalidProjects.length > 0) {
  106. toast.error(
  107. `You don't have access to the following project(s): ${invalidProjects.join(', ')}`
  108. )
  109. return
  110. }
  111. }
  112. const finalExpiresAt =
  113. values.expiresAt === CUSTOM_EXPIRY_VALUE
  114. ? values.customExpiryDate
  115. : getExpirationDate(values.expiresAt || '')
  116. const permissions = permissionRows
  117. .flatMap((row) => {
  118. const { resource, actions } = row
  119. return actions.flatMap((action) => mapPermissionToFGA(resource, action))
  120. })
  121. .filter(Boolean) as ScopedAccessTokenPermission[]
  122. if (!permissions || permissions.length === 0) {
  123. toast.error('Please configure at least one valid permission.')
  124. return
  125. }
  126. const finalPayload: ScopedAccessTokenCreateVariables = {
  127. name: values.tokenName,
  128. permissions,
  129. }
  130. if (finalExpiresAt) {
  131. finalPayload.expires_at = finalExpiresAt
  132. }
  133. if (
  134. values.resourceAccess === 'selected-orgs' &&
  135. values.selectedOrganizations &&
  136. values.selectedOrganizations.length > 0
  137. ) {
  138. finalPayload.organization_slugs = values.selectedOrganizations
  139. } else if (
  140. values.resourceAccess === 'selected-projects' &&
  141. values.selectedProjects &&
  142. values.selectedProjects.length > 0
  143. ) {
  144. finalPayload.project_refs = values.selectedProjects
  145. }
  146. if (!finalPayload.name || finalPayload.name.trim() === '') {
  147. toast.error('Please enter a token name.')
  148. return
  149. }
  150. if (!finalPayload.permissions || finalPayload.permissions.length === 0) {
  151. toast.error('Please configure at least one permission.')
  152. return
  153. }
  154. createAccessToken(finalPayload, {
  155. onSuccess: (data) => {
  156. track('access_token_created', {
  157. tokenType: 'scoped',
  158. expiryPreset: values.expiresAt || 'never',
  159. resourceAccess: values.resourceAccess,
  160. permissionCount: permissions.length,
  161. })
  162. toast.success('Access token created successfully')
  163. onCreateToken(data)
  164. handleClose()
  165. },
  166. onError: (error) => {
  167. if (error.message && error.message.includes("don't have access")) {
  168. toast.error(
  169. `Access Error: ${error.message}. Please verify you have access to the selected resources.`
  170. )
  171. } else {
  172. toast.error(`Failed to create access token: ${error.message}`)
  173. }
  174. },
  175. })
  176. }
  177. const handleClose = () => {
  178. form.reset({
  179. tokenName: '',
  180. expiresAt: EXPIRES_AT_OPTIONS['month'].value,
  181. customExpiryDate: undefined,
  182. resourceAccess: 'all-orgs',
  183. selectedOrganizations: [],
  184. selectedProjects: [],
  185. permissionRows: [],
  186. })
  187. onOpenChange(false)
  188. }
  189. const handleCustomDateChange = useCallback(
  190. (date: { date: string } | undefined) => {
  191. form.setValue('customExpiryDate', date?.date, { shouldValidate: true })
  192. },
  193. [form]
  194. )
  195. const handleCustomExpiryChange = useCallback(
  196. (isCustom: boolean) => {
  197. if (isCustom && !form.getValues('customExpiryDate')) {
  198. form.setValue('customExpiryDate', dayjs().endOf('day').toISOString(), {
  199. shouldValidate: true,
  200. })
  201. }
  202. if (!isCustom) {
  203. form.setValue('customExpiryDate', undefined, { shouldValidate: true })
  204. }
  205. },
  206. [form]
  207. )
  208. return (
  209. <Sheet
  210. open={visible}
  211. onOpenChange={(open) => {
  212. if (!open) {
  213. handleClose()
  214. } else {
  215. onOpenChange(open)
  216. }
  217. }}
  218. >
  219. <SheetContent
  220. showClose={false}
  221. size="default"
  222. className="min-w-[600px]! flex flex-col h-full gap-0"
  223. >
  224. <SheetHeader>
  225. <SheetTitle>
  226. {tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
  227. </SheetTitle>
  228. <SheetDescription className="sr-only">
  229. A form to generate a new scoped access token.
  230. </SheetDescription>
  231. </SheetHeader>
  232. <ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
  233. <div className="flex flex-col overflow-visible">
  234. {tokenScope === 'V0' && (
  235. <div className="px-4 sm:px-5 py-4 pb-4">
  236. <Admonition
  237. type="warning"
  238. title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
  239. description={
  240. <>
  241. <p>
  242. These include deleting organizations and projects which cannot be undone. As
  243. such, be very careful when using this API.
  244. </p>
  245. <div className="mt-4">
  246. <Button asChild type="default" icon={<ExternalLink />}>
  247. <Link
  248. href="https://api.supabase.com/api/v0"
  249. target="_blank"
  250. rel="noreferrer"
  251. >
  252. Experimental API documentation
  253. </Link>
  254. </Button>
  255. </div>
  256. </>
  257. }
  258. />
  259. </div>
  260. )}
  261. <Form {...form}>
  262. <div className="flex flex-col gap-0 overflow-visible">
  263. <BasicInfo
  264. control={form.control}
  265. expirationDate={expiresAt || ''}
  266. onCustomDateChange={handleCustomDateChange}
  267. onCustomExpiryChange={handleCustomExpiryChange}
  268. />
  269. <Separator />
  270. <ResourceAccess
  271. control={form.control}
  272. resourceAccess={resourceAccess}
  273. setValue={form.setValue}
  274. />
  275. <Separator />
  276. <Permissions
  277. setValue={form.setValue}
  278. watch={form.watch}
  279. resourceSearchOpen={resourceSearchOpen}
  280. setResourceSearchOpen={setResourceSearchOpen}
  281. />
  282. </div>
  283. </Form>
  284. </div>
  285. </ScrollArea>
  286. <SheetFooter className="justify-end! w-full mt-auto py-4 border-t">
  287. <div className="flex gap-2">
  288. <Button type="default" disabled={isPending} onClick={handleClose}>
  289. Cancel
  290. </Button>
  291. <Button onClick={form.handleSubmit(onSubmit)} loading={isPending}>
  292. Generate token
  293. </Button>
  294. </div>
  295. </SheetFooter>
  296. </SheetContent>
  297. </Sheet>
  298. )
  299. }