NewScopedTokenSheet.tsx328 lines · main
| 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 | |
| 22 | import { |
| 23 | CUSTOM_EXPIRY_VALUE, |
| 24 | EXPIRES_AT_OPTIONS, |
| 25 | type ScopedAccessTokenPermission, |
| 26 | } from '../AccessToken.constants' |
| 27 | import { TokenSchema, type TokenFormValues } from '../AccessToken.schemas' |
| 28 | import { getExpirationDate, mapPermissionToFGA } from '../AccessToken.utils' |
| 29 | import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData' |
| 30 | import { BasicInfo } from './Form/BasicInfo' |
| 31 | import { Permissions } from './Form/Permissions/Permissions' |
| 32 | import { ResourceAccess } from './Form/ResourceAccess/ResourceAccess' |
| 33 | import { |
| 34 | useAccessTokenCreateMutation, |
| 35 | type NewScopedAccessToken, |
| 36 | type ScopedAccessTokenCreateVariables, |
| 37 | } from '@/data/scoped-access-tokens/scoped-access-token-create-mutation' |
| 38 | import { useTrack } from '@/lib/telemetry/track' |
| 39 | |
| 40 | export interface NewScopedTokenSheetProps { |
| 41 | visible: boolean |
| 42 | onOpenChange: (open: boolean) => void |
| 43 | tokenScope: 'V0' | undefined |
| 44 | onCreateToken: (token: NewScopedAccessToken) => void |
| 45 | } |
| 46 | |
| 47 | export 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 | } |