diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts index 847b29fd213..4448dfb5f40 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts @@ -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' }, diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts index 1417df70d0d..5e4586d21b6 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts @@ -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 diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx new file mode 100644 index 00000000000..07efe992006 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx @@ -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 ( + <> + + + + + - - - - - +

+ ) : ( + <> + +
+ +
+ + + {showMissingPermissionsWarning && ( +
+ { + 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." + /> +
+ )} + + )} ) : ( - + { + handleSelectLegacyMode() + setStep('form') + }} + /> )} - + {isClassicMode ? ( + + ) : ( + + )}
{step === 'review' && ( - {step === 'form' ? ( + {step === 'form' && isClassicMode && ( + + )} + {step === 'form' && !isClassicMode && ( - ) : ( + )} + {step === 'review' && ( diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts index 3a2f288352f..1ef60b1b2f5 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts @@ -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) => { diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx index 285cb009a9a..e3f70e6b2c6 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx @@ -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 = { 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 ? ( ) : (

MCP tools

+

+ Need a token for the Supabase MCP server or full access to your account?{' '} + + . +

{mcpTools.length === 0 ? (

No MCP tools are enabled by the selected capabilities. diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx index 587ac573ec4..9d9b92dd55a 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx @@ -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 setValue: UseFormSetValue - /** 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 ( -

- - ( - - Need access to every organization and project?{' '} - -

- } - id="resourceAccess" - > - - { - field.onChange(value) - // Reset dependent selections when switching modes. - setValue('projectRefs', []) - if (value !== 'account') setValue('accountConfirmed', false) - }} +
+ ( + + Need a token with full access to your account or one for the Supabase MCP server?{' '} +
- } - /> - ))} - - - - )} - /> - - {resourceAccess === 'project' && ( - <> - ( - Organization
} - id="organizationSlugs" - > - - - - - )} - /> - ( - Projects} - id="projectRefs" - > - +

+ } + id="resourceAccess" + > + + { + field.onChange(value) + // Reset dependent selections when switching modes. + setValue('projectRefs', []) + }} + > + {CARD_OPTIONS.map((option) => ( + - 0 - ? 'Select projects' - : 'Select an organization first' - } - badgeLimit="wrap" - showIcon={true} - deletableBadge - ref={field.ref} - renderValue={(value) => projectsByRef[value]?.name} - className="min-w-auto" - /> - - - - {projectsForOrg.map((project) => ( - - {project.name} - - ))} - - -
-
- )} - /> - + label={ +
+
+ {option.name} + {option.recommended && Recommended} +
+ {option.description} +
+ } + /> + ))} + + + )} - {resourceAccess === 'organization' && ( + /> + + {resourceAccess === 'project' && ( + <> ( Organizations} + label={Organization} id="organizationSlugs" > - + + + + + )} + /> + ( + Projects} + id="projectRefs" + > + 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" /> - + - {organizations.map((organization) => ( - - {organization.name} + {projectsForOrg.map((project) => ( + + {project.name} ))} @@ -303,65 +230,45 @@ export const ResourceAccessStep = ({ control, setValue, error }: ResourceAccessS )} /> - )} - - - - {isAccount && ( - - -

- 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. -

-
- ( - <> - field.onChange(checked)} - /> - - - )} - /> -
- - - } - /> -
- )} -
- - {error &&

{error}

} + + )} + {resourceAccess === 'organization' && ( + ( + Organizations} + id="organizationSlugs" + > + + organizationsBySlug[value]?.name} + /> + + + + {organizations.map((organization) => ( + + {organization.name} + + ))} + + + + + )} + /> + )} ) } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx index 7e26582a7af..c066ac9f34a 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx @@ -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({ + 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( {}} />, { + profileContext: PROFILE_CONTEXT, + }) + beforeEach(() => { mockPermissionsMap() mockOrganizations() mockProjects() mockCreateToken() + mockCreateClassicToken() }) test('requires a token name', async () => { - customRender(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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' })) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx index 701f1b58646..e7a70677a58 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -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() + 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 ( - - - +
+ + + + +
{ {step === 'success' ? 'Token created' : 'Generate token'} - Generate a new scoped access token in two steps: configure, then review. + Configure and create a new access token. {step === 'success' && createdToken ? ( @@ -93,7 +134,7 @@ export const NewScopedTokenSheet = () => { /> ) : ( handleOpenChange(false, true)} /> diff --git a/apps/studio/pages/account/tokens.tsx b/apps/studio/pages/account/tokens.tsx index 7eebc76d97d..b8ad06552c4 100644 --- a/apps/studio/pages/account/tokens.tsx +++ b/apps/studio/pages/account/tokens.tsx @@ -57,7 +57,7 @@ const UserAccessTokens: NextPageWithLayout = () => { {scopedTokensEnabled ? ( - + ) : ( )}