feat(studio): add legacy token mode to scoped pat creation flow (#48844)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Replaces the scoped form's inline account-level access mode with a
proper legacy-token escape hatch: "Create legacy token" switches the
sheet to the classic form (name + expiry only) and creates through the
legacy endpoint, skipping the two-step review. Mirrors the mode-switch
links in both directions and restores the "Generate token for
experimental API" split-button dropdown, extracted into a shared
ExperimentalTokenDropdown.

Ported from origin/w3b6x9/scoped-pat-ui-rework, excluding its expiry
handling (shipped in #48811) and MCP-unsupported warnings (follow-up
PR).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for creating classic account-wide access tokens
alongside scoped tokens.
  * Added an experimental token dropdown for quick token creation.
* Added links to switch between scoped and legacy token creation flows.
* Classic token creation now provides dedicated warnings and simplified
access settings.

* **Improvements**
* Updated token access messaging, descriptions, and labels for clarity.

* **Tests**
* Expanded coverage for token creation, navigation, validation, and
clipboard behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kemal.earthandClaude Sonnet 5 authored and GitHub committed 2026-08-07 13:05:56 +01:00
1 parent 2e633a3fbe
commit 3a98b0c818
12 files changed
+492 -395

No files matched your search

@@ -6,6 +6,12 @@ export type ScopedAccessTokenPermission =
export const CUSTOM_EXPIRY_VALUE = 'custom'
/** Warning shown on both entry points that create a classic (account-wide) token. */
export const CLASSIC_TOKEN_WARNING = {
title: 'Access tokens can be used to control your whole account',
description: 'Be careful when sharing your tokens',
} as const
export const EXPIRES_AT_OPTIONS = {
hour: { value: 'hour', label: '1 hour' },
day: { value: 'day', label: '1 day' },
@@ -642,7 +642,7 @@ export const computeOverallRisk = (
): OverallRisk => {
const active = Object.entries(selection).filter(([, mode]) => mode !== 'none')
if (active.length === 0) {
return { level: 'Minimal', text: 'Minimal — no capabilities', tone: 'default' }
return { level: 'Minimal', text: 'Minimal — No capabilities', tone: 'default' }
}
const anyWrite = active.some(([, mode]) => mode === 'readwrite')
@@ -652,10 +652,10 @@ export const computeOverallRisk = (
const scopeWord =
resourceAccess === 'account'
? 'account-wide'
? 'Account-wide'
: resourceAccess === 'organization'
? 'organization-wide'
: 'single-project'
? 'Organization-wide'
: 'Single-project'
const accessWord = anyWrite ? 'read-write' : 'read-only'
let level: string
@@ -0,0 +1,60 @@
import { ChevronDown } from 'lucide-react'
import { useState } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { NewTokenDialog } from './NewTokenDialog'
import { type NewAccessToken } from '@/data/access-tokens/access-tokens-create-mutation'
interface ExperimentalTokenDropdownProps {
onCreateToken: (token: NewAccessToken) => void
}
/**
* The chevron half of the "Generate new token" split button: a dropdown whose single
* item opens the experimental API (V0 scope) token dialog. Rendered flush against a
* `rounded-r-none` primary button.
*/
export const ExperimentalTokenDropdown = ({ onCreateToken }: ExperimentalTokenDropdownProps) => {
const [visible, setVisible] = useState(false)
return (
<>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="primary"
aria-label="Choose token scope"
className="rounded-l-none px-[4px] py-[5px]"
icon={<ChevronDown />}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Choose token scope</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem key="experimental-token" onClick={() => setVisible(true)}>
<p className="block text-foreground">Generate token for experimental API</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NewTokenDialog
open={visible}
onOpenChange={setVisible}
tokenScope="V0"
onCreateToken={onCreateToken}
/>
</>
)
}
@@ -1,16 +1,7 @@
import { ChevronDown } from 'lucide-react'
import { useState } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Button } from 'ui'
import { ExperimentalTokenDropdown } from './ExperimentalTokenDropdown'
import { NewTokenDialog } from './NewTokenDialog'
import { type NewAccessToken } from '@/data/access-tokens/access-tokens-create-mutation'
@@ -20,56 +11,17 @@ export interface NewAccessTokenButtonProps {
export const NewTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => {
const [visible, setVisible] = useState(false)
const [tokenScope, setTokenScope] = useState<'V0' | undefined>(undefined)
return (
<>
<div className="flex items-center">
<Button
className="rounded-r-none px-3"
onClick={() => {
setTokenScope(undefined)
setVisible(true)
}}
>
<Button className="rounded-r-none px-3" onClick={() => setVisible(true)}>
Generate new token
</Button>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="primary"
aria-label="Choose token scope"
className="rounded-l-none px-[4px] py-[5px]"
icon={<ChevronDown />}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Choose token scope</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="experimental-token"
onClick={() => {
setTokenScope('V0')
setVisible(true)
}}
>
<div className="space-y-1">
<p className="block text-foreground">Generate token for experimental API</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ExperimentalTokenDropdown onCreateToken={onCreateToken} />
</div>
<NewTokenDialog
open={visible}
onOpenChange={setVisible}
tokenScope={tokenScope}
onCreateToken={onCreateToken}
/>
<NewTokenDialog open={visible} onOpenChange={setVisible} onCreateToken={onCreateToken} />
</>
)
}
@@ -27,7 +27,11 @@ import { Admonition } from 'ui-patterns/Admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { z } from 'zod'
import { CUSTOM_EXPIRY_VALUE, EXPIRES_AT_OPTIONS } from '../AccessToken.constants'
import {
CLASSIC_TOKEN_WARNING,
CUSTOM_EXPIRY_VALUE,
EXPIRES_AT_OPTIONS,
} from '../AccessToken.constants'
import { getExpirationDate, getMaxCustomExpiryDate } from '../AccessToken.utils'
import { DatePicker } from '@/components/ui/DatePicker'
import {
@@ -45,7 +49,7 @@ const TokenSchema = z.object({
export interface NewAccessTokenDialogProps {
open: boolean
tokenScope: 'V0' | undefined
tokenScope?: 'V0' | undefined
onOpenChange: (open: boolean) => void
onCreateToken: (token: NewAccessToken) => void
}
@@ -163,8 +167,8 @@ export const NewTokenDialog = ({
<Admonition
type="warning"
className="rounded-none border-t-0 border-x-0"
title="Access tokens can be used to control your whole account"
description="Be careful when sharing your tokens"
title={CLASSIC_TOKEN_WARNING.title}
description={CLASSIC_TOKEN_WARNING.description}
/>
)}
<DialogSection className="flex flex-col gap-4">
@@ -6,6 +6,7 @@ import { toast } from 'sonner'
import { Button, Form, ScrollArea, Separator, SheetClose, SheetFooter } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { CLASSIC_TOKEN_WARNING } from '../../AccessToken.constants'
import { countConfigured, PermissionMode } from '../../AccessToken.permissions'
import { DEFAULT_EXPIRY, TokenFormSchema, TokenFormValues } from './NewScopedTokenForm.utils'
import { NewScopedTokenFormReview } from './NewScopedTokenFormReview'
@@ -13,6 +14,7 @@ import { PermissionsAccordion } from './PermissionsAccordion'
import { ResourceAccessStep } from './ResourceAccessStep'
import { StepIndicator } from './StepIndicator'
import { TokenDetails } from './TokenDetails'
import { InlineLinkClassName } from '@/components/ui/InlineLink'
import { useGetEnabledEndpointsForCapability } from '@/data/scoped-access-tokens/permission-scope-map-query'
const FORM_ID = 'scoped-token-form'
@@ -24,7 +26,6 @@ const DEFAULT_VALUES: TokenFormValues = {
resourceAccess: 'project',
organizationSlugs: [],
projectRefs: [],
accountConfirmed: false,
permissions: {},
}
@@ -39,12 +40,6 @@ export const NewScopedTokenForm = ({
}) => {
const form = useForm<TokenFormValues>({
resolver: zodResolver(TokenFormSchema),
validate: ({ formValues }) => {
if (formValues.resourceAccess === 'account') {
if (!formValues.accountConfirmed) return 'Confirm account-level access to continue.'
}
return true
},
defaultValues: DEFAULT_VALUES,
mode: 'onChange',
})
@@ -52,6 +47,7 @@ export const NewScopedTokenForm = ({
const [formValues, setFormValues] = useState<TokenFormValues>(DEFAULT_VALUES)
const [showMissingPermissionsWarning, setShowMissingPermissionsWarning] = useState(false)
const resourceSectionRef = useRef<HTMLDivElement>(null)
const resourceAccess = useWatch({ control: form.control, name: 'resourceAccess' })
const selection = useWatch({ control: form.control, name: 'permissions' })
const configuredCount = useWatch({
control: form.control,
@@ -68,7 +64,23 @@ export const NewScopedTokenForm = ({
}
}, [onCancel, isError])
// 'account' switches to the classic token flow: name + expiry only, no permissions or review.
const isClassicMode = resourceAccess === 'account'
// Single owner of the mode switch, so every entry point resets the same dependent fields.
const handleSelectLegacyMode = () => {
form.setValue('resourceAccess', 'account', { shouldValidate: true })
form.setValue('organizationSlugs', [])
form.setValue('projectRefs', [])
// The fields unmount in legacy mode, so drop any validation errors they were holding.
form.clearErrors(['organizationSlugs', 'projectRefs'])
}
const handleReviewAccess = async (values: TokenFormValues) => {
if (values.resourceAccess === 'account') {
onCreateToken(values)
return
}
if (configuredCount === 0) {
setShowMissingPermissionsWarning(true)
return
@@ -88,37 +100,80 @@ export const NewScopedTokenForm = ({
{step === 'form' ? (
<Form {...form}>
<form id={FORM_ID} onSubmit={form.handleSubmit(handleReviewAccess)}>
<TokenDetails control={form.control} setValue={form.setValue} />
<Separator />
<div ref={resourceSectionRef}>
<ResourceAccessStep control={form.control} setValue={form.setValue} />
</div>
<Separator />
<PermissionsAccordion
selection={selection}
onChange={handlePermissionChange}
permissionScopeMap={permissionScopeMap}
/>
{showMissingPermissionsWarning && (
<div className="space-y-3 px-5 sm:px-6 pb-6">
{isClassicMode && (
<div className="px-5 sm:px-6 pt-6">
<Admonition
ref={(node) => {
node?.scrollIntoView()
}}
type="warning"
title="No permissions selected"
description="This token won't be able to do anything until you grant at least one permission."
className="mb-0"
title={CLASSIC_TOKEN_WARNING.title}
description={CLASSIC_TOKEN_WARNING.description}
/>
</div>
)}
<TokenDetails control={form.control} setValue={form.setValue} />
{isClassicMode ? (
<p className="px-5 sm:px-6 pb-6 text-foreground-lighter text-sm">
Only need a token for specific projects or organizations?{' '}
<button
type="button"
className={InlineLinkClassName}
onClick={() =>
form.setValue('resourceAccess', 'project', { shouldValidate: true })
}
tabIndex={0}
>
Create scoped token
</button>
</p>
) : (
<>
<Separator />
<div ref={resourceSectionRef}>
<ResourceAccessStep
control={form.control}
setValue={form.setValue}
onSelectLegacyToken={handleSelectLegacyMode}
/>
</div>
<Separator />
<PermissionsAccordion
selection={selection}
onChange={handlePermissionChange}
permissionScopeMap={permissionScopeMap}
/>
{showMissingPermissionsWarning && (
<div className="space-y-3 px-5 sm:px-6 pb-6">
<Admonition
ref={(node) => {
node?.scrollIntoView()
}}
type="warning"
title="No permissions selected"
description="This token won't be able to do anything until you grant at least one permission."
/>
</div>
)}
</>
)}
</form>
</Form>
) : (
<NewScopedTokenFormReview values={formValues} permissionScopeMap={permissionScopeMap} />
<NewScopedTokenFormReview
values={formValues}
permissionScopeMap={permissionScopeMap}
onSelectLegacyToken={() => {
handleSelectLegacyMode()
setStep('form')
}}
/>
)}
</ScrollArea>
<SheetFooter className="mt-auto flex w-full items-center justify-between! border-t py-4 px-5 sm:px-6">
<StepIndicator step={step === 'form' ? 1 : 2} total={2} label="Configure" />
{isClassicMode ? (
<span />
) : (
<StepIndicator step={step === 'form' ? 1 : 2} total={2} label="Configure" />
)}
<div className="flex gap-2">
{step === 'review' && (
<Button variant="default" disabled={isPending} onClick={() => setStep('form')}>
@@ -128,11 +183,17 @@ export const NewScopedTokenForm = ({
<SheetClose asChild disabled={isPending}>
<Button variant="default">Cancel</Button>
</SheetClose>
{step === 'form' ? (
{step === 'form' && isClassicMode && (
<Button type="submit" form={FORM_ID} loading={isPending}>
Generate token
</Button>
)}
{step === 'form' && !isClassicMode && (
<Button type="submit" form={FORM_ID} iconRight={<ChevronRight />}>
Review access
</Button>
) : (
)}
{step === 'review' && (
<Button loading={isPending} onClick={() => onCreateToken(formValues)}>
Create token
</Button>
@@ -58,7 +58,6 @@ export const TokenFormSchema = z
resourceAccess: z.enum(RESOURCE_ACCESS_MODES),
organizationSlugs: z.string().array().optional().default([]),
projectRefs: z.string().array().optional().default([]),
accountConfirmed: z.boolean().optional(),
permissions: z.record(z.string(), z.enum(['none', 'read', 'readwrite'])),
})
.superRefine((data, ctx) => {
@@ -14,6 +14,7 @@ import {
} from '../../AccessToken.permissions'
import { useOrgAndProjectData } from '../../hooks/useOrgAndProjectData'
import { EXPIRY_OPTIONS, type TokenFormValues } from './NewScopedTokenForm.utils'
import { InlineLinkClassName } from '@/components/ui/InlineLink'
import {
getEnabledEndpointsForCapability,
getEnabledMcpTools,
@@ -23,6 +24,8 @@ import {
interface ReviewStepProps {
values: TokenFormValues
permissionScopeMap: PermissionScopeMap | undefined
/** Switches the form back to step one in legacy (account-wide) token mode. */
onSelectLegacyToken: () => void
}
const RISK_TONE_VARIANT: Record<
@@ -44,7 +47,11 @@ const RISK_DOT_CLASS: Record<RiskLevel, string> = {
high: 'bg-destructive-600',
}
export const NewScopedTokenFormReview = ({ values, permissionScopeMap }: ReviewStepProps) => {
export const NewScopedTokenFormReview = ({
values,
permissionScopeMap,
onSelectLegacyToken,
}: ReviewStepProps) => {
const { organizations, projects } = useOrgAndProjectData()
const selection = values.permissions
const grantedScopes = useMemo(() => selectionToScopes(selection), [selection])
@@ -193,8 +200,8 @@ export const NewScopedTokenFormReview = ({ values, permissionScopeMap }: ReviewS
{hasCapabilities ? (
<Admonition
type="warning"
title="Token access can't be edited after creation"
description="Once created, a token's access can't be edited. To change it, revoke this token and create a new one."
title="Token access can't be updated after creation"
description="To change its access, delete this token and create a new one."
/>
) : (
<Admonition
@@ -250,6 +257,18 @@ export const NewScopedTokenFormReview = ({ values, permissionScopeMap }: ReviewS
<div className="flex flex-col gap-3">
<h3 className="text-sm">MCP tools</h3>
<p className="text-xs text-foreground-light">
Need a token for the Supabase MCP server or full access to your account?{' '}
<button
type="button"
className={InlineLinkClassName}
onClick={onSelectLegacyToken}
tabIndex={0}
>
Create a legacy token
</button>
.
</p>
{mcpTools.length === 0 ? (
<p className="text-xs text-foreground-light">
No MCP tools are enabled by the selected capabilities.
@@ -1,13 +1,9 @@
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useMemo } from 'react'
import type { Control, UseFormSetValue } from 'react-hook-form'
import {
Badge,
Checkbox,
cn,
FormControl,
FormField,
Label,
RadioGroupStacked,
RadioGroupStackedItem,
Select,
@@ -17,7 +13,6 @@ import {
SelectValue,
useWatch,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
MultiSelector,
@@ -31,6 +26,7 @@ import {
import type { ResourceAccessMode } from '../../AccessToken.permissions'
import { useOrgAndProjectData } from '../../hooks/useOrgAndProjectData'
import type { TokenFormValues } from './NewScopedTokenForm.utils'
import { InlineLinkClassName } from '@/components/ui/InlineLink'
import { ProjectInfoInfinite } from '@/data/projects/projects-infinite-query'
import { Organization } from '@/types'
@@ -38,8 +34,8 @@ interface ResourceAccessStepProps {
control: Control<TokenFormValues>
setValue: UseFormSetValue<TokenFormValues>
/** Inline error surfaced only after an attempt to advance. */
error?: string
/** Switches the form into legacy (account-wide) token mode. */
onSelectLegacyToken: () => void
}
const CARD_OPTIONS: {
@@ -51,17 +47,21 @@ const CARD_OPTIONS: {
{
value: 'project',
name: 'Project',
description: 'Access selected projects only.',
description: 'Access select projects in a single organization.',
recommended: true,
},
{
value: 'organization',
name: 'Organization',
description: 'Access all projects in selected organizations.',
description: 'Access all projects in select organizations.',
},
]
export const ResourceAccessStep = ({ control, setValue, error }: ResourceAccessStepProps) => {
export const ResourceAccessStep = ({
control,
setValue,
onSelectLegacyToken,
}: ResourceAccessStepProps) => {
const { organizations, projects } = useOrgAndProjectData()
const organizationsBySlug = useMemo(
() =>
@@ -88,213 +88,140 @@ export const ResourceAccessStep = ({ control, setValue, error }: ResourceAccessS
const resourceAccess = useWatch({ control, name: 'resourceAccess' })
const organizationSlugs = useWatch({ control, name: 'organizationSlugs', defaultValue: [] })
const accountConfirmed = useWatch({ control, name: 'accountConfirmed' })
const isAccount = resourceAccess === 'account'
const prefersReducedMotion = useReducedMotion()
const projectsForOrg = useMemo(
() => projects.filter((project) => organizationSlugs.includes(project.organization_slug)),
[projects, organizationSlugs]
)
const enableAccountLevel = () => {
setValue('resourceAccess', 'account', { shouldValidate: true })
setValue('organizationSlugs', [])
setValue('projectRefs', [])
}
const switchBackToSingleProject = () => {
setValue('resourceAccess', 'project', { shouldValidate: true })
setValue('accountConfirmed', false)
}
const exitTransition = prefersReducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.97, filter: 'blur(4px)' }
const enterTransition = prefersReducedMotion ? false : { opacity: 0, y: 16 }
const formBackdropAnimate = isAccount
? {
opacity: 0.5,
scale: prefersReducedMotion ? 1 : 0.98,
filter: prefersReducedMotion ? 'none' : 'blur(4px)',
}
: { opacity: 1, scale: 1, filter: 'none' }
return (
<section className="relative space-y-4 px-5 sm:px-6 py-6">
<motion.div
className={cn('space-y-4', isAccount && 'pointer-events-none')}
animate={formBackdropAnimate}
transition={{ duration: 0.2, ease: 'easeOut' }}
aria-hidden={isAccount}
inert={isAccount}
>
<FormField
control={control}
name="resourceAccess"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Resource access"
description={
<p className="text-foreground-lighter text-sm">
Need access to every organization and project?{' '}
<button
type="button"
className="text-foreground-light underline hover:text-foreground transition-colors"
onClick={enableAccountLevel}
tabIndex={isAccount ? -1 : 0}
>
Advanced options
</button>
</p>
}
id="resourceAccess"
>
<FormControl>
<RadioGroupStacked
value={resourceAccess}
onValueChange={(value) => {
field.onChange(value)
// Reset dependent selections when switching modes.
setValue('projectRefs', [])
if (value !== 'account') setValue('accountConfirmed', false)
}}
<section className="space-y-4 px-5 sm:px-6 py-6">
<FormField
control={control}
name="resourceAccess"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Resource access"
description={
<p className="text-foreground-lighter text-sm">
Need a token with full access to your account or one for the Supabase MCP server?{' '}
<button
type="button"
className={InlineLinkClassName}
onClick={onSelectLegacyToken}
tabIndex={0}
>
{CARD_OPTIONS.map((option) => (
<RadioGroupStackedItem
key={option.value}
id={option.value}
value={option.value}
className="w-full"
label={
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground">{option.name}</span>
{option.recommended && <Badge variant="success">Recommended</Badge>}
</div>
<span className="text-foreground-light">{option.description}</span>
</div>
}
/>
))}
</RadioGroupStacked>
</FormControl>
</FormItemLayout>
)}
/>
{resourceAccess === 'project' && (
<>
<FormField
control={control}
name="organizationSlugs"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={<span className="sr-only">Organization</span>}
id="organizationSlugs"
>
<FormControl>
<Select
value={field.value.length > 0 ? field.value[0] : ''}
onValueChange={(value) => {
field.onChange([value])
setValue('projectRefs', [])
}}
>
<SelectTrigger id="organizationSlugs" ref={field.ref}>
<SelectValue placeholder="Select an organization" asChild>
<span>{organizationsBySlug[field.value[0]]?.name}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.slug} value={org.slug}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={control}
name="projectRefs"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={<span className="sr-only">Projects</span>}
id="projectRefs"
>
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
disabled={!organizationSlugs}
Create legacy token
</button>
</p>
}
id="resourceAccess"
>
<FormControl>
<RadioGroupStacked
value={resourceAccess}
onValueChange={(value) => {
field.onChange(value)
// Reset dependent selections when switching modes.
setValue('projectRefs', [])
}}
>
{CARD_OPTIONS.map((option) => (
<RadioGroupStackedItem
key={option.value}
id={option.value}
value={option.value}
className="w-full"
>
<MultiSelectorTrigger
id="projectRefs"
mode="combobox"
label={
organizationSlugs.length > 0
? 'Select projects'
: 'Select an organization first'
}
badgeLimit="wrap"
showIcon={true}
deletableBadge
ref={field.ref}
renderValue={(value) => projectsByRef[value]?.name}
className="min-w-auto"
/>
<MultiSelectorContent>
<MultiSelectorInput placeholder="Search organizations" showResetIcon />
<MultiSelectorList>
{projectsForOrg.map((project) => (
<MultiSelectorItem key={project.ref} value={project.ref}>
{project.name}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</FormItemLayout>
)}
/>
</>
label={
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground">{option.name}</span>
{option.recommended && <Badge variant="success">Recommended</Badge>}
</div>
<span className="text-foreground-light">{option.description}</span>
</div>
}
/>
))}
</RadioGroupStacked>
</FormControl>
</FormItemLayout>
)}
{resourceAccess === 'organization' && (
/>
{resourceAccess === 'project' && (
<>
<FormField
control={control}
name="organizationSlugs"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={<span className="sr-only">Organizations</span>}
label={<span className="sr-only">Organization</span>}
id="organizationSlugs"
>
<MultiSelector onValuesChange={field.onChange} values={field.value}>
<FormControl>
<Select
value={field.value.length > 0 ? field.value[0] : ''}
onValueChange={(value) => {
field.onChange([value])
setValue('projectRefs', [])
}}
>
<SelectTrigger id="organizationSlugs" ref={field.ref}>
<SelectValue placeholder="Select an organization" asChild>
<span>{organizationsBySlug[field.value[0]]?.name}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.slug} value={org.slug}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={control}
name="projectRefs"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={<span className="sr-only">Projects</span>}
id="projectRefs"
>
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
disabled={!organizationSlugs}
className="w-full"
>
<MultiSelectorTrigger
id="organizationSlugs"
id="projectRefs"
mode="combobox"
label="Select organizations"
label={
organizationSlugs.length > 0
? 'Select projects'
: 'Select an organization first'
}
badgeLimit="wrap"
showIcon={false}
showIcon={true}
deletableBadge
className="w-full"
ref={field.ref}
renderValue={(value) => organizationsBySlug[value]?.name}
renderValue={(value) => projectsByRef[value]?.name}
className="min-w-auto"
/>
<MultiSelectorContent>
<MultiSelectorInput placeholder="Search organizations" showResetIcon />
<MultiSelectorInput placeholder="Search projects" showResetIcon />
<MultiSelectorList>
{organizations.map((organization) => (
<MultiSelectorItem key={organization.slug} value={organization.slug}>
{organization.name}
{projectsForOrg.map((project) => (
<MultiSelectorItem key={project.ref} value={project.ref}>
{project.name}
</MultiSelectorItem>
))}
</MultiSelectorList>
@@ -303,65 +230,45 @@ export const ResourceAccessStep = ({ control, setValue, error }: ResourceAccessS
</FormItemLayout>
)}
/>
)}
</motion.div>
<AnimatePresence>
{isAccount && (
<motion.div
key="account"
className="absolute inset-x-5 bottom-6 space-y-4 sm:inset-x-6"
initial={enterTransition}
animate={{ opacity: 1, y: 0 }}
exit={exitTransition}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<Admonition
type="warning"
title="Account-level access is broad."
description={
<div className="space-y-3">
<p>
This token can reach every organization and project you have access to. Prefer a
single project or organization unless you specifically need account-wide access.
</p>
<div className="flex items-start gap-2">
<FormField
control={control}
name="accountConfirmed"
render={({ field }) => (
<>
<Checkbox
id="accountConfirmed"
checked={accountConfirmed ?? false}
onCheckedChange={(checked) => field.onChange(checked)}
/>
<Label
htmlFor="accountConfirmed"
className="text-xs text-foreground-light"
>
I understand this token is not limited to one project or organization.
</Label>
</>
)}
/>
</div>
<button
type="button"
className="text-xs text-foreground-light underline hover:text-foreground transition-colors"
onClick={switchBackToSingleProject}
tabIndex={0}
>
Switch back
</button>
</div>
}
/>
</motion.div>
)}
</AnimatePresence>
{error && <p className="text-xs text-destructive">{error}</p>}
</>
)}
{resourceAccess === 'organization' && (
<FormField
control={control}
name="organizationSlugs"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={<span className="sr-only">Organizations</span>}
id="organizationSlugs"
>
<MultiSelector onValuesChange={field.onChange} values={field.value}>
<MultiSelectorTrigger
id="organizationSlugs"
mode="combobox"
label="Select organizations"
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full"
ref={field.ref}
renderValue={(value) => organizationsBySlug[value]?.name}
/>
<MultiSelectorContent>
<MultiSelectorInput placeholder="Search organizations" showResetIcon />
<MultiSelectorList>
{organizations.map((organization) => (
<MultiSelectorItem key={organization.slug} value={organization.slug}>
{organization.name}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</FormItemLayout>
)}
/>
)}
</section>
)
}
@@ -13,6 +13,7 @@ import { addAPIMock } from '@/tests/lib/msw'
type OrganizationResponse = components['schemas']['OrganizationResponse']
type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse']
type CreateTokenResponse = components['schemas']['CreateScopedAccessTokenResponse']
type CreateClassicTokenResponse = components['schemas']['CreateAccessTokenResponse']
const user = userEvent.setup({
writeToClipboard: true,
@@ -101,6 +102,22 @@ const mockCreateToken = () =>
}),
})
const mockCreateClassicToken = () =>
addAPIMock({
method: 'post',
path: '/platform/profile/access-tokens',
response: () =>
HttpResponse.json<CreateClassicTokenResponse>({
created_at: '',
expires_at: null,
id: 1,
last_used_at: null,
name: 'test',
token: 'a_classic_token_value',
token_alias: '',
}),
})
/**
* Permission categories render collapsed, so a category has to be expanded before its
* rows are in the DOM.
@@ -109,14 +126,20 @@ const expandPermissionCategory = async (name: string) =>
fireEvent.click(await screen.findByRole('button', { name: new RegExp(`^${name}`) }))
describe('NewScopedTokenSheet', () => {
const renderSheet = () =>
customRender(<NewScopedTokenSheet onCreateExperimentalToken={() => {}} />, {
profileContext: PROFILE_CONTEXT,
})
beforeEach(() => {
mockPermissionsMap()
mockOrganizations()
mockProjects()
mockCreateToken()
mockCreateClassicToken()
})
test('requires a token name', async () => {
customRender(<NewScopedTokenSheet />, { profileContext: PROFILE_CONTEXT })
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
@@ -124,7 +147,7 @@ describe('NewScopedTokenSheet', () => {
})
// Project scope tests
test('requires an organization when scope is Project', async () => {
customRender(<NewScopedTokenSheet />, { profileContext: PROFILE_CONTEXT })
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.type(await screen.findByLabelText('Name'), 'test')
@@ -132,7 +155,7 @@ describe('NewScopedTokenSheet', () => {
expect(await screen.findByText('Please select an organization to continue.'))
})
test('requires a project when scope is Project', async () => {
customRender(<NewScopedTokenSheet />, { profileContext: PROFILE_CONTEXT })
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.type(await screen.findByLabelText('Name'), 'test')
@@ -142,7 +165,7 @@ describe('NewScopedTokenSheet', () => {
expect(await screen.findByText('Please select a project to continue.'))
})
test('requires permissions when scope is Project', async () => {
customRender(<NewScopedTokenSheet />, { profileContext: PROFILE_CONTEXT })
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.type(await screen.findByLabelText('Name'), 'test')
@@ -154,7 +177,7 @@ describe('NewScopedTokenSheet', () => {
expect(await screen.findByText('No permissions selected', { selector: '[role="alert"] *' }))
})
test('creates the token when scope is Project', async () => {
customRender(<NewScopedTokenSheet />, { profileContext: PROFILE_CONTEXT })
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.type(await screen.findByLabelText('Name'), 'test')
@@ -168,7 +191,7 @@ describe('NewScopedTokenSheet', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
// Review screen
await screen.findByText('Low Risk')
await screen.findByText('single-project read-only access')
await screen.findByText('Single-project read-only access')
fireEvent.click(await screen.findByRole('button', { name: 'Create token' }))
// If we can click this checkbox, the token was created
// Must be a real click, which focuses the button: nothing holds focus once the form
@@ -185,7 +208,7 @@ describe('NewScopedTokenSheet', () => {
// Organization scope tests
test('requires an organization when scope is Organization', async () => {
customRender(<NewScopedTokenSheet />, { profileContext: PROFILE_CONTEXT })
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.type(await screen.findByLabelText('Name'), 'test')
@@ -194,7 +217,7 @@ describe('NewScopedTokenSheet', () => {
expect(await screen.findByText('Please select an organization to continue.'))
})
test('requires permissions when scope is Organization', async () => {
customRender(<NewScopedTokenSheet />, { profileContext: PROFILE_CONTEXT })
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.type(await screen.findByLabelText('Name'), 'test')
@@ -205,7 +228,7 @@ describe('NewScopedTokenSheet', () => {
expect(await screen.findByText('No permissions selected', { selector: '[role="alert"] *' }))
})
test('creates the token when scope is Organization', async () => {
customRender(<NewScopedTokenSheet />, { profileContext: PROFILE_CONTEXT })
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.type(await screen.findByLabelText('Name'), 'test')
@@ -218,7 +241,7 @@ describe('NewScopedTokenSheet', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
// Review screen
await screen.findByText('Low Risk')
await screen.findByText('organization-wide read-only access')
await screen.findByText('Organization-wide read-only access')
fireEvent.click(await screen.findByRole('button', { name: 'Create token' }))
// If we can click this checkbox, the token was created
// Must be a real click, which focuses the button: nothing holds focus once the form
@@ -233,46 +256,71 @@ describe('NewScopedTokenSheet', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})
// Account scope tests
test('requires permissions when scope is Account', async () => {
customRender(<NewScopedTokenSheet />, { profileContext: PROFILE_CONTEXT })
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.type(await screen.findByLabelText('Name'), 'test')
await user.click(await screen.findByText('Advanced options'))
test('opens the experimental API dialog from the dropdown', async () => {
renderSheet()
await user.click(await screen.findByRole('button', { name: 'Choose token scope' }))
await user.click(
await screen.findByText(
'I understand this token is not limited to one project or organization.'
)
await screen.findByRole('menuitem', { name: 'Generate token for experimental API' })
)
// The experimental API dialog is open
await screen.findByText(
'The experimental API provides additional endpoints which allows you to manage your organizations and projects.'
)
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
expect(await screen.findByText('No permissions selected', { selector: '[role="alert"] *' }))
})
test('creates the token when scope is Account', async () => {
customRender(<NewScopedTokenSheet />, { profileContext: PROFILE_CONTEXT })
// Classic (account-wide) token tests
test('switches to the classic token form via the legacy link', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
// Trigger a resource validation error before entering legacy mode
await user.type(await screen.findByLabelText('Name'), 'test')
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
await screen.findByText('Please select an organization to continue.')
await user.click(await screen.findByText('Create legacy token'))
// The classic warning replaces resource access and permissions
await screen.findByText('Access tokens can be used to control your whole account')
expect(screen.queryByRole('button', { name: 'Review access' })).toBeNull()
await screen.findByRole('button', { name: 'Generate token' })
// Switching back restores the scoped form, without resurfacing the stale resource error
await user.click(await screen.findByText('Create scoped token'))
await screen.findByRole('button', { name: 'Review access' })
expect(screen.queryByText('Access tokens can be used to control your whole account')).toBeNull()
expect(screen.queryByText('Please select an organization to continue.')).toBeNull()
})
test('switches to the classic token form from the review step MCP notice', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.type(await screen.findByLabelText('Name'), 'test')
await user.click(await screen.findByText('Advanced options'))
await user.click(
await screen.findByText(
'I understand this token is not limited to one project or organization.'
)
)
fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' }))
fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' }))
fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' }))
fireEvent.click(await screen.findByRole('option', { name: 'Project 1' }))
await expandPermissionCategory('Project')
fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false }))
fireEvent.click(await screen.findByRole('option', { name: 'Read' }))
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
// Review screen
await screen.findByText('Elevated Risk')
await screen.findByText('account-wide read-only access')
fireEvent.click(await screen.findByRole('button', { name: 'Create token' }))
// If we can click this checkbox, the token was created
// The review step's own legacy-token link switches back into legacy mode
await user.click(await screen.findByText('Create a legacy token'))
await screen.findByText('Access tokens can be used to control your whole account')
await screen.findByRole('button', { name: 'Generate token' })
expect(screen.queryByRole('button', { name: 'Review access' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Create token' })).toBeNull()
})
test('creates a classic token via the legacy link', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.type(await screen.findByLabelText('Name'), 'test')
await user.click(await screen.findByText('Create legacy token'))
fireEvent.click(await screen.findByRole('button', { name: 'Generate token' }))
// If we can click this button, the token was created
// Must be a real click, which focuses the button: nothing holds focus once the form
// unmounts, and copyToClipboard bails out when the document has no focus
await user.click(await screen.findByRole('button', { name: 'Copy' }))
await waitFor(async () =>
expect(await window.navigator.clipboard.readText()).toEqual('a_token_value')
expect(await window.navigator.clipboard.readText()).toEqual('a_classic_token_value')
)
fireEvent.click(await screen.findByLabelText('I have copied the key and stored it securely'))
fireEvent.click(await screen.findByRole('button', { name: 'Done' }))
@@ -11,9 +11,14 @@ import {
} from 'ui'
import { selectionToScopes } from '../AccessToken.permissions'
import { ExperimentalTokenDropdown } from '../Classic/ExperimentalTokenDropdown'
import { NewScopedTokenForm } from './Form/NewScopedTokenForm'
import { getExpiryDate, type TokenFormValues } from './Form/NewScopedTokenForm.utils'
import { NewScopedTokenSuccess } from './Form/NewScopedTokenSuccess'
import {
useAccessTokenCreateMutation,
type NewAccessToken,
} from '@/data/access-tokens/access-tokens-create-mutation'
import {
useScopedAccessTokenCreateMutation,
type NewScopedAccessToken,
@@ -21,21 +26,54 @@ import {
} from '@/data/scoped-access-tokens/scoped-access-token-create-mutation'
import { useTrack } from '@/lib/telemetry/track'
export const NewScopedTokenSheet = () => {
interface NewScopedTokenSheetProps {
/** Called with the created token when one is generated through the experimental API dialog. */
onCreateExperimentalToken: (token: NewAccessToken) => void
}
export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedTokenSheetProps) => {
const [isOpen, setIsOpen] = useState(false)
const track = useTrack()
const { mutate: createToken, isPending } = useScopedAccessTokenCreateMutation()
const { mutate: createToken, isPending: isCreatingScopedToken } =
useScopedAccessTokenCreateMutation()
const { mutate: createClassicToken, isPending: isCreatingClassicToken } =
useAccessTokenCreateMutation()
const [step, setStep] = useState<'form' | 'success'>('form')
const [createdToken, setCreatedToken] = useState<NewScopedAccessToken | undefined>()
const [createdToken, setCreatedToken] = useState<
NewScopedAccessToken | NewAccessToken | undefined
>()
const showCreatedToken = (data: NewScopedAccessToken | NewAccessToken) => {
toast.success('Access token created successfully')
setCreatedToken(data)
setStep('success')
}
const handleCreate = (values: TokenFormValues) => {
const permissions = selectionToScopes(values.permissions)
if (permissions.length === 0) return
const expires_at =
values.expiresAt === 'custom' ? values.customExpiryDate : getExpiryDate(values.expiresAt)
// 'account' access creates a classic (account-wide) token via the legacy endpoint.
if (values.resourceAccess === 'account') {
createClassicToken(
{ name: values.tokenName.trim(), expires_at },
{
onSuccess: (data) => {
track('access_token_created', {
tokenType: 'classic',
expiryPreset: values.expiresAt,
})
showCreatedToken(data)
},
}
)
return
}
const permissions = selectionToScopes(values.permissions)
if (permissions.length === 0) return
const payload: ScopedAccessTokenCreateVariables = {
name: values.tokenName.trim(),
permissions,
@@ -54,9 +92,7 @@ export const NewScopedTokenSheet = () => {
resourceAccess: values.resourceAccess,
permissionCount: permissions.length,
})
toast.success('Access token created successfully')
setCreatedToken(data)
setStep('success')
showCreatedToken(data)
},
})
}
@@ -71,9 +107,14 @@ export const NewScopedTokenSheet = () => {
return (
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetTrigger asChild>
<Button variant="primary">Generate new token</Button>
</SheetTrigger>
<div className="flex items-center">
<SheetTrigger asChild>
<Button variant="primary" className="rounded-r-none px-3">
Generate new token
</Button>
</SheetTrigger>
<ExperimentalTokenDropdown onCreateToken={onCreateExperimentalToken} />
</div>
<SheetContent
showClose={false}
size="default"
@@ -82,7 +123,7 @@ export const NewScopedTokenSheet = () => {
<SheetHeader>
<SheetTitle>{step === 'success' ? 'Token created' : 'Generate token'}</SheetTitle>
<SheetDescription className="sr-only">
Generate a new scoped access token in two steps: configure, then review.
Configure and create a new access token.
</SheetDescription>
</SheetHeader>
{step === 'success' && createdToken ? (
@@ -93,7 +134,7 @@ export const NewScopedTokenSheet = () => {
/>
) : (
<NewScopedTokenForm
isPending={isPending}
isPending={isCreatingScopedToken || isCreatingClassicToken}
onCreateToken={handleCreate}
onCancel={() => handleOpenChange(false, true)}
/>
+1 -1
View File
@@ -57,7 +57,7 @@ const UserAccessTokens: NextPageWithLayout = () => {
</a>
</Button>
{scopedTokensEnabled ? (
<NewScopedTokenSheet />
<NewScopedTokenSheet onCreateExperimentalToken={setNewToken} />
) : (
<NewTokenButton onCreateToken={setNewToken} />
)}