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