diff --git a/apps/design-system/content/docs/accessibility.mdx b/apps/design-system/content/docs/accessibility.mdx
index efdede1da8f57..c1660c5b963db 100644
--- a/apps/design-system/content/docs/accessibility.mdx
+++ b/apps/design-system/content/docs/accessibility.mdx
@@ -59,16 +59,17 @@ focus-visible:ring-offset-background
Uses `outline` (not `ring`) so it paints reliably on interactive `
`s. Tailwind `ring` is `box-shadow`, which browsers often skip on `display: table-row` (notably Safari). Do not put `focus-ring` or raw `ring-*` on a `
`, and do not add `outline-hidden` alongside `focus-inset`. `outline-hidden` sets `outline-style: none` and will hide the indicator.
```txt
+outline: 2px solid transparent
+outline-offset: -2px
+transition-property: color, background-color, border-color, ...
+
&:focus-visible {
- outline-style: solid
- outline-width: 2px
- outline-offset: -2px
outline-color: var(--ring)
border-radius: var(--radius-md)
}
```
-`outline-hidden` is always on (not `focus-visible:`-prefixed) so mouse click does not show the browser’s default outline; the focus indicator replaces it for keyboard focus only.
+`focus-ring` keeps `outline-hidden` always on so mouse clicks do not show the browser’s default outline. `focus-inset` reserves a transparent outline instead. Its transition property list deliberately excludes outline properties so the keyboard focus indicator appears immediately, even when a call site uses `transition-all`.
Rules:
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx
index 0d4cb0b02e4e4..1054062e337a9 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx
@@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useReducedMotion } from 'common'
import { ChevronRight, X } from 'lucide-react'
-import { useEffect, useRef, useState } from 'react'
+import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
@@ -43,21 +43,36 @@ const DEFAULT_VALUES: TokenFormValues = {
permissions: {},
}
-export const NewScopedTokenForm = ({
- isPending,
- onCreateToken,
- onCancel,
-}: {
- isPending: boolean
- onCreateToken: (values: TokenFormValues) => void
- onCancel: () => void
-}) => {
+export interface NewScopedTokenFormHandle {
+ getAbandonmentContext: () => {
+ resourceAccess: TokenFormValues['resourceAccess']
+ formStep: 'form' | 'review'
+ isFormTouched: boolean
+ }
+}
+
+export const NewScopedTokenForm = forwardRef<
+ NewScopedTokenFormHandle,
+ {
+ isPending: boolean
+ onCreateToken: (values: TokenFormValues) => void
+ onCancel: () => void
+ }
+>(({ isPending, onCreateToken, onCancel }, ref) => {
const form = useForm({
resolver: zodResolver(TokenFormSchema),
defaultValues: DEFAULT_VALUES,
mode: 'onChange',
})
const [step, setStep] = useState<'form' | 'review'>('form')
+ const { isDirty } = form.formState
+ useImperativeHandle(ref, () => ({
+ getAbandonmentContext: () => ({
+ resourceAccess: form.getValues('resourceAccess'),
+ formStep: step,
+ isFormTouched: isDirty,
+ }),
+ }))
const [formValues, setFormValues] = useState(DEFAULT_VALUES)
const [isCreateHintDismissed, setIsCreateHintDismissed] = useState(false)
const [missingPermissionsAttempts, setMissingPermissionsAttempts] = useState(0)
@@ -89,13 +104,15 @@ export const NewScopedTokenForm = ({
const isReducedMotionPreferred = useReducedMotion()
const isReducedMotionPreferredRef = useRef(isReducedMotionPreferred)
isReducedMotionPreferredRef.current = isReducedMotionPreferred
+ const onCancelRef = useRef(onCancel)
+ onCancelRef.current = onCancel
useEffect(() => {
if (isError) {
toast.error('Something went wrong, try again')
- onCancel()
+ onCancelRef.current()
}
- }, [onCancel, isError])
+ }, [isError])
useEffect(() => {
if (missingPermissionsAttempts === 0) return
@@ -267,4 +284,6 @@ export const NewScopedTokenForm = ({
>
)
-}
+})
+
+NewScopedTokenForm.displayName = 'NewScopedTokenForm'
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 4699369cdadef..e76c2f11e556b 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx
@@ -248,8 +248,90 @@ describe('NewScopedTokenSheet', () => {
})
// Dialog has been closed
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
+ // Completing the flow via Done must not also emit a dismissed event
+ expect(mockTrack).not.toHaveBeenCalledWith(
+ 'access_token_creation_sheet_dismissed',
+ expect.anything()
+ )
+ }, 10_000)
+
+ test('tracks dismissal with the in-progress resourceAccess and touched state on Cancel', async () => {
+ renderSheet()
+ fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
+ await screen.findByRole('dialog')
+ await user.click(await screen.findByRole('radio', { name: /Organization/ }))
+ fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }))
+ expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', {
+ resourceAccess: 'organization',
+ formStep: 'form',
+ isFormTouched: true,
+ trigger: 'user',
+ })
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
+ })
+
+ test('tracks dismissal with the untouched default resourceAccess on Escape', async () => {
+ renderSheet()
+ fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
+ const dialog = await screen.findByRole('dialog')
+ fireEvent.keyDown(dialog, { key: 'Escape', code: 'Escape' })
+ expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', {
+ resourceAccess: 'project',
+ formStep: 'form',
+ isFormTouched: false,
+ trigger: 'user',
+ })
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
+ })
+
+ test('tracks the review step when the sheet is dismissed from the review screen', async () => {
+ renderSheet()
+ fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
+ await screen.findByRole('dialog')
+ await user.type(await screen.findByLabelText('Name'), 'test')
+ 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' }))
+ await screen.findByText('Medium risk')
+ fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }))
+ expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', {
+ resourceAccess: 'project',
+ formStep: 'review',
+ isFormTouched: true,
+ trigger: 'user',
+ })
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
}, 10_000)
+ test('tracks a permissions load error as the dismissal trigger and closes the sheet', async () => {
+ addAPIMock({
+ method: 'get',
+ // @ts-expect-error Studio API is missing from types
+ path: '/scoped-access-token-permissions',
+ response: () => HttpResponse.json({ message: 'unavailable' }, { status: 500 }),
+ })
+ renderSheet()
+ fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
+ await waitFor(() =>
+ expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', {
+ resourceAccess: 'project',
+ formStep: 'form',
+ isFormTouched: false,
+ trigger: 'permissions_load_error',
+ })
+ )
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
+ const dismissedCalls = mockTrack.mock.calls.filter(
+ ([event]) => event === 'access_token_creation_sheet_dismissed'
+ )
+ expect(dismissedCalls).toHaveLength(1)
+ })
+
// Organization scope tests
test('requires an organization when scope is Organization', async () => {
renderSheet()
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx
index 77eca42f91f64..81053bcd510fa 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useRef, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
@@ -12,7 +12,7 @@ import {
import { selectionToScopes } from '../AccessToken.permissions'
import { ExperimentalTokenDropdown } from '../Classic/ExperimentalTokenDropdown'
-import { NewScopedTokenForm } from './Form/NewScopedTokenForm'
+import { NewScopedTokenForm, type NewScopedTokenFormHandle } from './Form/NewScopedTokenForm'
import { getExpiryDate, type TokenFormValues } from './Form/NewScopedTokenForm.utils'
import { NewScopedTokenSuccess } from './Form/NewScopedTokenSuccess'
import { TokenDocsButtons } from './TokenDocsButtons'
@@ -35,6 +35,7 @@ interface NewScopedTokenSheetProps {
export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedTokenSheetProps) => {
const [isOpen, setIsOpen] = useState(false)
const track = useTrack()
+ const formRef = useRef(null)
const { mutate: createToken, isPending: isCreatingScopedToken } =
useScopedAccessTokenCreateMutation()
const { mutate: createClassicToken, isPending: isCreatingClassicToken } =
@@ -102,21 +103,30 @@ export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedToke
})
}
+ const trackDismissed = (trigger: 'user' | 'permissions_load_error') => {
+ const abandonmentContext = formRef.current?.getAbandonmentContext()
+ track('access_token_creation_sheet_dismissed', {
+ resourceAccess: abandonmentContext?.resourceAccess ?? 'project',
+ formStep: abandonmentContext?.formStep ?? 'form',
+ isFormTouched: abandonmentContext?.isFormTouched ?? false,
+ trigger,
+ })
+ }
+
// By default, if users created a token successfully, they can't click outside the sheet to close it
// as we need to make sure they copied the new token first
const handleOpenChange = (open: boolean, isSafe = false) => {
if (open === false && step === 'success' && !isSafe) return
- if (open === false) {
- track('access_token_creation_sheet_dismissed', {
- // Can be non when users closes the sheet without completing the token creation
- tokenType: createdToken?.tokenType ?? 'none',
- step,
- })
- }
+ if (open === false && !isSafe) trackDismissed('user')
setStep('form')
setIsOpen(open)
}
+ const handlePermissionsLoadError = () => {
+ trackDismissed('permissions_load_error')
+ handleOpenChange(false, true)
+ }
+
return (
@@ -151,9 +161,10 @@ export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedToke
/>
) : (
handleOpenChange(false, true)}
+ onCancel={handlePermissionsLoadError}
/>
)}
diff --git a/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts b/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts
index 2c863fdabb8dd..fc5bf7712ec68 100644
--- a/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts
+++ b/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts
@@ -57,7 +57,7 @@ export type ConditionalValue =
// Schema Types - Modes
// ============================================================================
-export const CONNECT_MODES = ['framework', 'direct', 'orm', 'mcp', 'server'] as const
+export const CONNECT_MODES = ['framework', 'direct', 'orm', 'mcp', 'server', 'warehouse'] as const
export type ConnectMode = (typeof CONNECT_MODES)[number]
export interface ModeDefinition {
diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx
index 2d891eacbc1af..83d117a1043cd 100644
--- a/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx
+++ b/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx
@@ -1,5 +1,5 @@
import { cva, type VariantProps } from 'class-variance-authority'
-import { Box, Cable, Database, Server, Sparkles } from 'lucide-react'
+import { Box, Cable, Database, Server, Sparkles, Warehouse } from 'lucide-react'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { cn } from 'ui'
@@ -11,6 +11,7 @@ const MODE_ICONS: Record = {
orm: ,
mcp: ,
server: ,
+ warehouse: ,
}
/** Maps mode count → container-query breakpoint used when collapsing to a single row. */
diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx
index a6684e24f0af4..33eaf0e619f06 100644
--- a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx
+++ b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx
@@ -12,6 +12,7 @@ import { useAvailableConnectModes } from './useAvailableConnectModes'
import { useConnectSheetParams } from './useConnectSheetParams'
import { useConnectSheetShortcut } from './useConnectSheetShortcut'
import { useConnectState } from './useConnectState'
+import { WarehouseModePanel } from './WarehouseModePanel/WarehouseModePanel'
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
@@ -177,18 +178,26 @@ export const ConnectSheet = () => {
/>
+ )}
+
+
+ >
)}
-
-
diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.tsx b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.tsx
new file mode 100644
index 0000000000000..b17e2ecddb346
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.tsx
@@ -0,0 +1,211 @@
+import { useParams } from 'common'
+import { KeyRound } from 'lucide-react'
+import Link from 'next/link'
+import { Badge, Button } from 'ui'
+import { Admonition } from 'ui-patterns/Admonition'
+import { CodeBlock } from 'ui-patterns/CodeBlock'
+import { Input } from 'ui-patterns/DataInputs/Input'
+import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+
+import type { WarehouseCatalogCredentials } from './WarehouseModePanel.utils'
+import { AlertError } from '@/components/ui/AlertError'
+import { useUpdateWarehouseCatalogMutation } from '@/data/warehouse/warehouse-catalog-mutation'
+import { useWarehouseCatalogQuery } from '@/data/warehouse/warehouse-catalog-query'
+import {
+ DUCKLAKE_METADATA_PASSWORD_ENV_VAR,
+ DUCKLAKE_S3_SECRET_ENV_VAR,
+ getDuckLakeSetupScript,
+ getWarehouseFlightSqlConnectionString,
+ getWarehouseFlightSqlEndpoint,
+ getWarehouseUsqlCommand,
+ parseWarehouseCatalogUrl,
+} from '@/lib/warehouse'
+
+export interface WarehouseConnectionDetailsProps {
+ onEditTables: () => void
+}
+
+function FieldRow({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
+ return (
+ // `minmax(0,1fr)` rather than `1fr`: a 1fr track keeps `min-width: auto`, so a long
+ // single-line value (the FlightSQL connection string) stretches the track past the panel
+ // instead of truncating inside it.
+
+ {label}
+
{children}
+
+ )
+}
+
+/**
+ * The DuckDB setup script inlines everything except the two passwords, which it reads via
+ * `getenv()` — so those are the only credential values surfaced as their own rows here.
+ */
+function DuckLakeSetup({ credentials }: { credentials: WarehouseCatalogCredentials }) {
+ const connection = parseWarehouseCatalogUrl(credentials.catalog_url)
+
+ if (connection === null) {
+ return (
+
+ Attach this project's Warehouse directly from DuckDB. The script reads both passwords from
+ environment variables — set these before running it:
+
+ {DUCKLAKE_S3_SECRET_ENV_VAR}}>
+
+
+ {DUCKLAKE_METADATA_PASSWORD_ENV_VAR}}
+ >
+
+
+ {/*
+ `className` is what switches CodeBlock from its plain fallback to the syntax
+ highlighter — without it the SQL renders unhighlighted and the blank lines between steps
+ collapse.
+ */}
+
+