NewTokenDialog.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import dayjs from 'dayjs'
  3. import { ExternalLink } from 'lucide-react'
  4. import { useState } from 'react'
  5. import { useForm, type SubmitHandler } from 'react-hook-form'
  6. import { toast } from 'sonner'
  7. import {
  8. Button,
  9. Dialog,
  10. DialogContent,
  11. DialogFooter,
  12. DialogHeader,
  13. DialogSection,
  14. DialogSectionSeparator,
  15. DialogTitle,
  16. Form,
  17. FormControl,
  18. FormField,
  19. Input,
  20. Select,
  21. SelectContent,
  22. SelectItem,
  23. SelectTrigger,
  24. SelectValue,
  25. WarningIcon,
  26. } from 'ui'
  27. import { Admonition } from 'ui-patterns'
  28. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  29. import { z } from 'zod'
  30. import {
  31. CUSTOM_EXPIRY_VALUE,
  32. EXPIRES_AT_OPTIONS,
  33. NON_EXPIRING_TOKEN_VALUE,
  34. } from '../AccessToken.constants'
  35. import { getExpirationDate } from '../AccessToken.utils'
  36. import { DatePicker } from '@/components/ui/DatePicker'
  37. import {
  38. useAccessTokenCreateMutation,
  39. type NewAccessToken,
  40. } from '@/data/access-tokens/access-tokens-create-mutation'
  41. import { useTrack } from '@/lib/telemetry/track'
  42. const formId = 'new-access-token-form'
  43. const TokenSchema = z.object({
  44. tokenName: z.string().min(1, 'Please enter a name for the token'),
  45. expiresAt: z.preprocess(
  46. (val) => (val === NON_EXPIRING_TOKEN_VALUE ? undefined : val),
  47. z.string().optional()
  48. ),
  49. })
  50. export interface NewAccessTokenDialogProps {
  51. open: boolean
  52. tokenScope: 'V0' | undefined
  53. onOpenChange: (open: boolean) => void
  54. onCreateToken: (token: NewAccessToken) => void
  55. }
  56. export const NewTokenDialog = ({
  57. open,
  58. tokenScope,
  59. onOpenChange,
  60. onCreateToken,
  61. }: NewAccessTokenDialogProps) => {
  62. const [customExpiryDate, setCustomExpiryDate] = useState<{ date: string } | undefined>(undefined)
  63. const [isCustomExpiry, setIsCustomExpiry] = useState(false)
  64. const form = useForm<z.infer<typeof TokenSchema>>({
  65. resolver: zodResolver(TokenSchema as any),
  66. defaultValues: { tokenName: '', expiresAt: EXPIRES_AT_OPTIONS['month'].value },
  67. mode: 'onChange',
  68. })
  69. const track = useTrack()
  70. const { mutate: createAccessToken, isPending } = useAccessTokenCreateMutation()
  71. const onSubmit: SubmitHandler<z.infer<typeof TokenSchema>> = async (values) => {
  72. let expiresAt: string | undefined
  73. if (isCustomExpiry && customExpiryDate) {
  74. expiresAt = customExpiryDate.date
  75. } else {
  76. expiresAt = getExpirationDate(values.expiresAt || '')
  77. }
  78. createAccessToken(
  79. { name: values.tokenName, scope: tokenScope, expires_at: expiresAt },
  80. {
  81. onSuccess: (data) => {
  82. track('access_token_created', {
  83. tokenType: 'classic',
  84. expiryPreset: values.expiresAt || 'never',
  85. })
  86. toast.success('Access token created successfully')
  87. onCreateToken(data)
  88. handleClose()
  89. },
  90. }
  91. )
  92. }
  93. const handleClose = () => {
  94. form.reset({ tokenName: '' })
  95. setCustomExpiryDate(undefined)
  96. setIsCustomExpiry(false)
  97. onOpenChange(false)
  98. }
  99. const handleExpiryChange = (value: string) => {
  100. if (value === CUSTOM_EXPIRY_VALUE) {
  101. setIsCustomExpiry(true)
  102. // Set a default custom date (today at 23:59:59)
  103. const defaultCustomDate = {
  104. date: dayjs().endOf('day').toISOString(),
  105. }
  106. setCustomExpiryDate(defaultCustomDate)
  107. form.setValue('expiresAt', value)
  108. } else {
  109. setIsCustomExpiry(false)
  110. setCustomExpiryDate(undefined)
  111. form.setValue('expiresAt', value)
  112. }
  113. }
  114. const handleCustomDateChange = (value: { date: string }) => {
  115. setCustomExpiryDate(value)
  116. }
  117. return (
  118. <Dialog
  119. open={open}
  120. onOpenChange={(open) => {
  121. if (!open) {
  122. form.reset()
  123. setCustomExpiryDate(undefined)
  124. setIsCustomExpiry(false)
  125. }
  126. onOpenChange(open)
  127. }}
  128. >
  129. <DialogContent>
  130. <DialogHeader>
  131. <DialogTitle>
  132. {tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
  133. </DialogTitle>
  134. </DialogHeader>
  135. <DialogSectionSeparator />
  136. {tokenScope === 'V0' ? (
  137. <Admonition
  138. type="warning"
  139. className="rounded-none border-t-0 border-x-0"
  140. title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
  141. description={
  142. <>
  143. <p>
  144. These include deleting organizations and projects which cannot be undone. As such,
  145. be very careful when using this API.
  146. </p>
  147. <div className="mt-4">
  148. <Button asChild type="default" icon={<ExternalLink />}>
  149. <a href="https://api.supabase.com/api/v0" target="_blank" rel="noreferrer">
  150. Experimental API documentation
  151. </a>
  152. </Button>
  153. </div>
  154. </>
  155. }
  156. />
  157. ) : (
  158. <Admonition
  159. type="warning"
  160. className="rounded-none border-t-0 border-x-0"
  161. title="Access tokens can be used to control your whole account"
  162. description="Be careful when sharing your tokens"
  163. />
  164. )}
  165. <DialogSection className="flex flex-col gap-4">
  166. <Form {...form}>
  167. <form
  168. id={formId}
  169. className="flex flex-col gap-4"
  170. onSubmit={form.handleSubmit(onSubmit)}
  171. >
  172. <FormField
  173. key="tokenName"
  174. name="tokenName"
  175. control={form.control}
  176. render={({ field }) => (
  177. <FormItemLayout name="tokenName" label="Name">
  178. <FormControl>
  179. <Input
  180. id="tokenName"
  181. {...field}
  182. placeholder="Provide a name for your token"
  183. />
  184. </FormControl>
  185. </FormItemLayout>
  186. )}
  187. />
  188. <FormField
  189. key="expiresAt"
  190. name="expiresAt"
  191. control={form.control}
  192. render={({ field }) => (
  193. <FormItemLayout name="expiresAt" label="Expires in">
  194. <div className="flex gap-2">
  195. <FormControl className="grow">
  196. <Select value={field.value} onValueChange={handleExpiryChange}>
  197. <SelectTrigger>
  198. <SelectValue placeholder="Expires at" />
  199. </SelectTrigger>
  200. <SelectContent>
  201. {Object.values(EXPIRES_AT_OPTIONS).map(
  202. (option: { value: string; label: string }) => (
  203. <SelectItem key={option.value} value={option.value}>
  204. {option.label}
  205. </SelectItem>
  206. )
  207. )}
  208. </SelectContent>
  209. </Select>
  210. </FormControl>
  211. {isCustomExpiry && (
  212. <DatePicker
  213. selectsRange={false}
  214. triggerButtonSize="small"
  215. contentSide="top"
  216. to={customExpiryDate?.date}
  217. minDate={new Date()}
  218. maxDate={dayjs().add(1, 'year').toDate()}
  219. onChange={(date) => {
  220. if (date.to) handleCustomDateChange({ date: date.to })
  221. }}
  222. />
  223. )}
  224. </div>
  225. {field.value === NON_EXPIRING_TOKEN_VALUE && (
  226. <div className="w-full flex gap-x-2 items-center mt-3 mx-0.5">
  227. <WarningIcon />
  228. <span className="text-xs text-left text-foreground-lighter">
  229. Make sure to keep your non-expiring token safe and secure.
  230. </span>
  231. </div>
  232. )}
  233. </FormItemLayout>
  234. )}
  235. />
  236. </form>
  237. </Form>
  238. </DialogSection>
  239. <DialogFooter>
  240. <Button
  241. type="default"
  242. disabled={isPending}
  243. onClick={() => {
  244. form.reset()
  245. setCustomExpiryDate(undefined)
  246. setIsCustomExpiry(false)
  247. onOpenChange(false)
  248. }}
  249. >
  250. Cancel
  251. </Button>
  252. <Button form={formId} htmlType="submit" loading={isPending}>
  253. Generate token
  254. </Button>
  255. </DialogFooter>
  256. </DialogContent>
  257. </Dialog>
  258. )
  259. }