mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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
|
||||
|
||||
+60
@@ -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">
|
||||
|
||||
+90
-29
@@ -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>
|
||||
|
||||
-1
@@ -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) => {
|
||||
|
||||
+22
-3
@@ -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.
|
||||
|
||||
+157
-250
@@ -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>
|
||||
)
|
||||
}
|
||||
+84
-36
@@ -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' }))
|
||||
|
||||
+55
-14
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -57,7 +57,7 @@ const UserAccessTokens: NextPageWithLayout = () => {
|
||||
</a>
|
||||
</Button>
|
||||
{scopedTokensEnabled ? (
|
||||
<NewScopedTokenSheet />
|
||||
<NewScopedTokenSheet onCreateExperimentalToken={setNewToken} />
|
||||
) : (
|
||||
<NewTokenButton onCreateToken={setNewToken} />
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user