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