Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useReducedMotion } from 'common'
import { ChevronRight, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
Expand Down Expand Up @@ -58,10 +59,10 @@ export const NewScopedTokenForm = ({
})
const [step, setStep] = useState<'form' | 'review'>('form')
const [formValues, setFormValues] = useState<TokenFormValues>(DEFAULT_VALUES)
// Dismissal sticks for the sheet's lifetime, so bouncing between steps doesn't resurface it.
const [isCreateHintDismissed, setIsCreateHintDismissed] = useState(false)
const [showMissingPermissionsWarning, setShowMissingPermissionsWarning] = useState(false)
const [missingPermissionsAttempts, setMissingPermissionsAttempts] = useState(0)
const resourceSectionRef = useRef<HTMLDivElement>(null)
const missingPermissionsRef = useRef<HTMLDivElement>(null)
const resourceAccess = useWatch({ control: form.control, name: 'resourceAccess' })
const selection = useWatch({ control: form.control, name: 'permissions' })
const organizationSlugs = useWatch({
Expand All @@ -85,6 +86,9 @@ export const NewScopedTokenForm = ({
})

const { data: permissionScopeMap, isError } = useGetEnabledEndpointsForCapability()
const isReducedMotionPreferred = useReducedMotion()
const isReducedMotionPreferredRef = useRef(isReducedMotionPreferred)
isReducedMotionPreferredRef.current = isReducedMotionPreferred

useEffect(() => {
if (isError) {
Expand All @@ -93,15 +97,20 @@ export const NewScopedTokenForm = ({
}
}, [onCancel, isError])

// 'account' switches to the classic token flow: name + expiry only, no permissions or review.
useEffect(() => {
if (missingPermissionsAttempts === 0) return
missingPermissionsRef.current?.scrollIntoView({
behavior: isReducedMotionPreferredRef.current ? 'auto' : 'smooth',
block: 'nearest',
})
}, [missingPermissionsAttempts])

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'])
}

Expand All @@ -111,7 +120,7 @@ export const NewScopedTokenForm = ({
return
}
if (configuredCount === 0) {
setShowMissingPermissionsWarning(true)
setMissingPermissionsAttempts((attempts) => attempts + 1)
return
}
setFormValues(values)
Expand All @@ -120,20 +129,17 @@ export const NewScopedTokenForm = ({

const handlePermissionChange = (key: string, mode: PermissionMode) => {
form.setValue('permissions', { ...selection, [key]: mode })
if (mode !== 'none') setShowMissingPermissionsWarning(false)
if (mode !== 'none') setMissingPermissionsAttempts(0)
}

const handleApplyPreset = (preset: PermissionPreset) => {
const next = applyPreset(preset, selection)
form.setValue('permissions', next)
if (countConfigured(next) > 0) setShowMissingPermissionsWarning(false)
if (countConfigured(next) > 0) setMissingPermissionsAttempts(0)
}

return (
<>
{/* Radix wraps viewport children in an inline-styled display:table div that grows to fit
the widest child, which would let one long endpoint path expand the sheet instead of
clipping — force it back to block so widths are bounded and rows can truncate. */}
<ScrollArea className="flex-1 [&>[data-radix-scroll-area-viewport]>div]:block!">
{step === 'form' ? (
<Form {...form}>
Expand Down Expand Up @@ -180,12 +186,9 @@ export const NewScopedTokenForm = ({
onApplyPreset={handleApplyPreset}
access={access}
/>
{showMissingPermissionsWarning && (
<div className="space-y-3 px-5 sm:px-6 pb-6">
{missingPermissionsAttempts > 0 && (
<div ref={missingPermissionsRef} 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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { platformComponents as components } from 'api-types'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, test } from 'vitest'
import { beforeEach, describe, expect, test, vi } from 'vitest'

import { NewScopedTokenSheet } from './NewScopedTokenSheet'
import type { ProfileContextType } from '@/lib/profile'
Expand All @@ -15,6 +15,12 @@ type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse']
type CreateTokenResponse = components['schemas']['CreateScopedAccessTokenResponse']
type CreateClassicTokenResponse = components['schemas']['CreateAccessTokenResponse']

const mockUseReducedMotion = vi.fn(() => false)
vi.mock('common', async (importOriginal) => {
const actual = (await importOriginal()) as typeof import('common')
return { ...actual, useReducedMotion: () => mockUseReducedMotion() }
})

const user = userEvent.setup({
writeToClipboard: true,
})
Expand Down Expand Up @@ -227,6 +233,59 @@ describe('NewScopedTokenSheet', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
expect(await screen.findByText('No permissions selected', { selector: '[role="alert"] *' }))
})
test('scrolls the missing permissions warning into view once per attempt', async () => {
const scrollIntoView = vi.spyOn(window.HTMLElement.prototype, 'scrollIntoView')
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.findByRole('radio', { name: /Organization/ }))
fireEvent.click(await screen.findByRole('combobox', { name: 'Organizations' }))
fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' }))
scrollIntoView.mockClear()

fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
await screen.findByText('No permissions selected', { selector: '[role="alert"] *' })
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1))
expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'smooth', block: 'nearest' })

// Re-rendering the form while the warning is up must not scroll again
await expandPermissionCategory('Database')
expect(scrollIntoView).toHaveBeenCalledTimes(1)

fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(2))
scrollIntoView.mockRestore()
})
test('does not re-scroll when the motion preference changes while the warning is visible', async () => {
const scrollIntoView = vi.spyOn(window.HTMLElement.prototype, 'scrollIntoView')
try {
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.findByRole('radio', { name: /Organization/ }))
fireEvent.click(await screen.findByRole('combobox', { name: 'Organizations' }))
fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' }))

fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
await screen.findByText('No permissions selected', { selector: '[role="alert"] *' })
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1))

mockUseReducedMotion.mockReturnValue(true)
// Round-tripping the resource-access scope re-renders the form (resourceAccess is
// watched at the top level) without touching missingPermissionsAttempts, so the
// warning stays up — this is what would surface a stale effect dependency on the
// motion preference.
await user.click(await screen.findByRole('radio', { name: /Project/ }))
await user.click(await screen.findByRole('radio', { name: /Organization/ }))
await screen.findByText('No permissions selected', { selector: '[role="alert"] *' })
expect(scrollIntoView).toHaveBeenCalledTimes(1)
} finally {
mockUseReducedMotion.mockReturnValue(false)
scrollIntoView.mockRestore()
}
})
test('creates the token when scope is Organization', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ export const BillingCustomerData = () => {
isSuccess,
} = useOrganizationCustomerProfileQuery(
{ slug },
{ enabled: canReadBillingCustomerData && inView }
{
enabled: canReadBillingCustomerData && inView,
select: (data) => (data ? { address: data.address, billing_name: data.billing_name } : data),
}
)

const {
Expand Down
Loading
Loading