diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index e093c4df664..26fb6b35189 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -363,6 +363,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp - [x] A `routes/logout.tsx` ← `pages/logout.tsx` - [x] A `routes/maintenance.tsx` ← `pages/maintenance.tsx` - [x] A `routes/verify-email.tsx` ← `pages/verify-email.tsx` +- [x] A `routes/mcp/secrets.tsx` ← `pages/mcp/secrets.tsx` (MCP elicitation URL-mode handoff; page brings its own `InterstitialLayout`, so it stays outside `_app/`.) ### Error pages (handled at root) diff --git a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx index 8c652163256..0c265f31754 100644 --- a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' import { toast } from 'sonner' +import { MCP_SECRETS_ROUTE } from '@/components/interfaces/MCP/Secrets/McpSecrets.constants' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { useProjectDetailQuery } from '@/data/projects/project-detail-query' import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory' @@ -38,6 +39,7 @@ export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { // this is used by database.dev, usually as /new/new-project '/new/[slug]', '/join', + MCP_SECRETS_ROUTE, ] /** diff --git a/apps/studio/components/interfaces/MCP/InterstitialShell.tsx b/apps/studio/components/interfaces/MCP/InterstitialShell.tsx new file mode 100644 index 00000000000..2eec2a32b54 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/InterstitialShell.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react' +import { cn, Skeleton } from 'ui' + +import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout' + +export const InterstitialShell = ({ + title, + subtitle, + children, +}: { + title: ReactNode + subtitle: ReactNode + children: ReactNode +}) => ( + } + title={title} + description={subtitle} + descriptionClassName="text-foreground-light" + > +
{children}
+
+) + +export const InterstitialShellSkeleton = ({ children }: { children: ReactNode }) => ( + } + subtitle={ +
+ + +
+ } + > + {children} +
+) + +export const InterstitialFooter = ({ + children, + align = 'center', +}: { + children: ReactNode + align?: 'center' | 'start' +}) => ( +

+ {children} +

+) diff --git a/apps/studio/components/interfaces/MCP/InterstitialTerminalScreen.tsx b/apps/studio/components/interfaces/MCP/InterstitialTerminalScreen.tsx new file mode 100644 index 00000000000..93f1d1255a8 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/InterstitialTerminalScreen.tsx @@ -0,0 +1,24 @@ +import { Admonition } from 'ui-patterns/Admonition' + +import { InterstitialFooter, InterstitialShell } from './InterstitialShell' + +export type InterstitialTerminalCopy = { + title: string + subtitle: string + calloutTitle: string + calloutBody: string + footer: string +} + +export const InterstitialTerminalScreen = ({ + title, + subtitle, + calloutTitle, + calloutBody, + footer, +}: InterstitialTerminalCopy) => ( + + + {footer} + +) diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.constants.ts b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.constants.ts new file mode 100644 index 00000000000..bea5ba8befb --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.constants.ts @@ -0,0 +1,15 @@ +export const MCP_SECRETS_ROUTE = '/mcp/secrets' + +export const MCP_SECRETS_FLAG = 'McpElicitURLMode' + +export const UNKNOWN_CLIENT_LABEL = 'your AI client' + +export const SECRETS_TOOL_NAME = 'create_edge_function_secret' + +export const MAX_SECRET_NAME_LENGTH = 256 +export const RESERVED_SECRET_NAME_PREFIX = 'SUPABASE_' + +export const IS_SECRETS_MOCK_MODE_ENABLED = + process.env.NODE_ENV === 'development' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.params.test.ts b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.params.test.ts new file mode 100644 index 00000000000..1c0b2438802 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.params.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { buildSecretsSignInPath, parseSecretsParams } from './McpSecrets.params' + +describe('parseSecretsParams', () => { + it('reads the project ref and secret name', () => { + const params = parseSecretsParams({ ref: 'abcdefghijklmnopqrst', name: 'OPENAI_API_KEY' }) + + expect(params.ref).toBe('abcdefghijklmnopqrst') + expect(params.name).toBe('OPENAI_API_KEY') + }) + + it('ignores params it has never seen rather than rejecting them', () => { + const params = parseSecretsParams({ + ref: 'abcdefghijklmnopqrst', + name: 'OPENAI_API_KEY', + somethingMintedLater: 'v2', + }) + + expect(params.ref).toBe('abcdefghijklmnopqrst') + expect(params.name).toBe('OPENAI_API_KEY') + }) + + it('treats missing params as absent so the page can render as expired', () => { + expect(parseSecretsParams({})).toMatchObject({ ref: undefined, name: undefined }) + }) + + it('rejects a ref that could not be a project ref', () => { + expect(parseSecretsParams({ ref: '../../etc', name: 'KEY' }).ref).toBeUndefined() + expect(parseSecretsParams({ ref: '', name: 'KEY' }).ref).toBeUndefined() + }) + + it('keeps the secret name exactly as minted', () => { + expect(parseSecretsParams({ ref: 'abc', name: 'my.weird-Name_1' }).name).toBe('my.weird-Name_1') + }) + + it('receives the params already decoded and does not decode them again', () => { + expect(parseSecretsParams({ ref: 'abc', name: 'MY KEY' }).name).toBe('MY KEY') + expect(parseSecretsParams({ ref: 'abc', name: 'MY%20KEY' }).name).toBe('MY%20KEY') + }) + + it('rejects a blank secret name', () => { + expect(parseSecretsParams({ ref: 'abc', name: ' ' }).name).toBeUndefined() + expect(parseSecretsParams({ ref: 'abc', name: '' }).name).toBeUndefined() + }) + + it('mirrors the platform length limit', () => { + expect(parseSecretsParams({ ref: 'abc', name: 'a'.repeat(256) }).name).toHaveLength(256) + expect(parseSecretsParams({ ref: 'abc', name: 'a'.repeat(257) }).name).toBeUndefined() + }) + + it('mirrors the platform ban on the SUPABASE_ prefix', () => { + expect(parseSecretsParams({ ref: 'abc', name: 'SUPABASE_ANON_KEY' }).name).toBeUndefined() + expect(parseSecretsParams({ ref: 'abc', name: 'MY_SUPABASE_KEY' }).name).toBe('MY_SUPABASE_KEY') + }) + + it('does not let one malformed param take out the other', () => { + const params = parseSecretsParams({ ref: 'abc', name: 'SUPABASE_ANON_KEY' }) + + expect(params.ref).toBe('abc') + expect(params.name).toBeUndefined() + }) + + it('never surfaces the reserved handle param', () => { + expect(parseSecretsParams({ ref: 'abc', name: 'KEY', i: 'handle' })).not.toHaveProperty('i') + }) +}) + +describe('buildSecretsSignInPath', () => { + it('keeps the elicitation params as siblings of returnTo', () => { + expect(buildSecretsSignInPath({ ref: 'abc', name: 'OPENAI_API_KEY' })).toBe( + '/sign-in?returnTo=%2Fmcp%2Fsecrets&ref=abc&name=OPENAI_API_KEY' + ) + }) + + it('percent-encodes names that are not URL-safe', () => { + expect(buildSecretsSignInPath({ ref: 'abc', name: 'a b&c' })).toBe( + '/sign-in?returnTo=%2Fmcp%2Fsecrets&ref=abc&name=a+b%26c' + ) + }) + + it('omits params it does not have', () => { + expect(buildSecretsSignInPath({ ref: undefined, name: undefined })).toBe( + '/sign-in?returnTo=%2Fmcp%2Fsecrets' + ) + }) +}) + +describe('the ?state= override', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + }) + + it('is inert unless the build opted in', async () => { + expect( + parseSecretsParams({ ref: 'abc', name: 'KEY', state: 'stored' }).dev.state + ).toBeUndefined() + }) + + it('drives the screen in local and staging builds', async () => { + vi.stubEnv('NEXT_PUBLIC_ENVIRONMENT', 'staging') + vi.resetModules() + + const { parseSecretsParams: parseWithOverrides } = await import('./McpSecrets.params') + + expect(parseWithOverrides({ ref: 'abc', name: 'KEY', state: 'stored' }).dev.state).toBe( + 'stored' + ) + expect( + parseWithOverrides({ ref: 'abc', name: 'KEY', state: 'nonsense' }).dev.state + ).toBeUndefined() + }) +}) diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.params.ts b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.params.ts new file mode 100644 index 00000000000..7f3693e8e1c --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.params.ts @@ -0,0 +1,88 @@ +import z from 'zod' + +import { + IS_SECRETS_MOCK_MODE_ENABLED, + MAX_SECRET_NAME_LENGTH, + MCP_SECRETS_ROUTE, + RESERVED_SECRET_NAME_PREFIX, +} from './McpSecrets.constants' + +export const DEV_SECRETS_STATES = [ + 'loading', + 'form', + 'stored', + 'stored-timeout', + 'already-stored', + 'expired', + 'cancelled', + 'paused', + 'wrong-account', + 'error', +] as const + +export type DevSecretsState = (typeof DEV_SECRETS_STATES)[number] + +export type SecretsParams = { + ref: string | undefined + name: string | undefined + dev: { state: DevSecretsState | undefined } +} + +const EMPTY_DEV_PARAMS: SecretsParams['dev'] = { state: undefined } + +const projectRefSchema = z + .string() + .regex(/^[a-zA-Z0-9_-]{1,64}$/) + .optional() + .catch(undefined) + +const secretNameSchema = z + .string() + .min(1) + .max(MAX_SECRET_NAME_LENGTH) + .refine((value) => value.trim().length > 0) + .refine((value) => !value.startsWith(RESERVED_SECRET_NAME_PREFIX)) + .optional() + .catch(undefined) + +const secretsParamsSchema = z + .object({ + ref: projectRefSchema, + name: secretNameSchema, + i: z.string().trim().min(1).optional().catch(undefined), + }) + .passthrough() + +const devSecretsParamsSchema = z + .object({ + state: z.enum(DEV_SECRETS_STATES).optional().catch(undefined), + }) + .passthrough() + +export type SecretsSearchParams = Record + +export function parseSecretsParams(searchParams: SecretsSearchParams): SecretsParams { + const parsed = secretsParamsSchema.safeParse(searchParams) + const ref = parsed.success ? parsed.data.ref : undefined + const name = parsed.success ? parsed.data.name : undefined + + if (!IS_SECRETS_MOCK_MODE_ENABLED) { + return { ref, name, dev: EMPTY_DEV_PARAMS } + } + + const parsedDev = devSecretsParamsSchema.safeParse(searchParams) + + return { + ref, + name, + dev: parsedDev.success ? { state: parsedDev.data.state } : EMPTY_DEV_PARAMS, + } +} + +export function buildSecretsSignInPath(params: Pick) { + const search = new URLSearchParams({ returnTo: MCP_SECRETS_ROUTE }) + if (params.ref !== undefined) search.set('ref', params.ref) + if (params.name !== undefined) search.set('name', params.name) + + return `/sign-in?${search.toString()}` +} diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.providers.test.ts b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.providers.test.ts new file mode 100644 index 00000000000..272166a5c2e --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.providers.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { getProviderHint } from './McpSecrets.providers' + +describe('getProviderHint', () => { + it('matches the provider anywhere in the name, case-insensitively', () => { + for (const name of ['OPENAI_API_KEY', 'openai-key', 'prod_OpenAi_token']) { + expect(getProviderHint(name)?.name).toBe('OpenAI') + } + }) + + it('carries the prefix and dashboard link the form renders', () => { + expect(getProviderHint('RESEND_API_KEY')).toEqual({ + name: 'Resend', + prefix: 're_', + dashboardUrl: 'https://resend.com/api-keys', + }) + }) + + it('keeps providers with several valid key shapes prefix-free', () => { + expect(getProviderHint('STRIPE_SECRET_KEY')?.prefix).toBeUndefined() + expect(getProviderHint('STRIPE_SECRET_KEY')?.dashboardUrl).toBe( + 'https://dashboard.stripe.com/apikeys' + ) + }) + + it('does not confuse providers whose key prefixes overlap', () => { + expect(getProviderHint('ANTHROPIC_API_KEY')?.prefix).toBe('sk-ant-') + expect(getProviderHint('OPENAI_API_KEY')?.prefix).toBe('sk-') + }) + + it('gives no hint at all for a name it does not recognize', () => { + expect(getProviderHint('MY_WEBHOOK_TOKEN')).toBeUndefined() + expect(getProviderHint('')).toBeUndefined() + }) +}) diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.providers.ts b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.providers.ts new file mode 100644 index 00000000000..f08b8369f25 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.providers.ts @@ -0,0 +1,40 @@ +import type { SecretProviderHint } from './McpSecrets.types' + +const PROVIDER_HINTS: ReadonlyArray<{ match: string; hint: SecretProviderHint }> = [ + { + match: 'openai', + hint: { + name: 'OpenAI', + prefix: 'sk-', + dashboardUrl: 'https://platform.openai.com/api-keys', + }, + }, + { + match: 'anthropic', + hint: { + name: 'Anthropic', + prefix: 'sk-ant-', + dashboardUrl: 'https://console.anthropic.com/settings/keys', + }, + }, + { + match: 'resend', + hint: { + name: 'Resend', + prefix: 're_', + dashboardUrl: 'https://resend.com/api-keys', + }, + }, + { + match: 'stripe', + hint: { + name: 'Stripe', + dashboardUrl: 'https://dashboard.stripe.com/apikeys', + }, + }, +] + +export function getProviderHint(keyName: string): SecretProviderHint | undefined { + const needle = keyName.toLowerCase() + return PROVIDER_HINTS.find(({ match }) => needle.includes(match))?.hint +} diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.tsx b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.tsx new file mode 100644 index 00000000000..e489bb73f08 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.tsx @@ -0,0 +1,54 @@ +import { useFeatureFlags, useFlag, useParams } from 'common' +import { useMemo } from 'react' + +import { InterstitialTerminalScreen } from '../InterstitialTerminalScreen' +import { MCP_SECRETS_FLAG } from './McpSecrets.constants' +import { buildSecretsSignInPath, parseSecretsParams } from './McpSecrets.params' +import { getSecretsAnnouncement, getSecretsCopy } from './McpSecrets.utils' +import { McpSecretsCard } from './McpSecretsCard' +import { McpSecretsSkeleton } from './McpSecretsSkeleton' +import { useSecretRequest } from './useSecretRequest' +import { BASE_PATH, IS_PLATFORM } from '@/lib/constants' + +const PAUSED_STATE = { status: 'paused' } as const + +export const McpSecrets = () => { + const searchParams = useParams() + const params = useMemo(() => parseSecretsParams(searchParams), [searchParams]) + + const { hasLoaded } = useFeatureFlags() + const isUrlModeEnabled = useFlag(MCP_SECRETS_FLAG) + const areFlagsResolved = !IS_PLATFORM || !!hasLoaded + + const { state, isSaving, saveSecret, cancelRequest } = useSecretRequest(params) + + const handleSwitchAccount = () => { + window.location.assign(`${BASE_PATH}${buildSecretsSignInPath(params)}`) + } + + const resolvedState = isUrlModeEnabled ? state : PAUSED_STATE + + return ( + <> +

+ {getSecretsAnnouncement(areFlagsResolved ? resolvedState : undefined)} +

+ + {!areFlagsResolved && } + + {areFlagsResolved && !isUrlModeEnabled && ( + + )} + + {areFlagsResolved && isUrlModeEnabled && ( + + )} + + ) +} diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.types.ts b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.types.ts new file mode 100644 index 00000000000..59a8db61b40 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.types.ts @@ -0,0 +1,31 @@ +export type SecretProviderHint = { + name: string + prefix?: string + dashboardUrl?: string +} + +export type SecretRequest = { + tool: string + ref: string + project: string + account: string + keyName: string + providerHint?: SecretProviderHint + existingSecret?: { updatedAt: string | undefined } +} + +export type SecretsState = + | { status: 'loading' } + | { status: 'form'; request: SecretRequest } + | { status: 'stored'; request: SecretRequest; timedOut: boolean } + | { status: 'already-stored'; request: SecretRequest } + | { status: 'expired' } + | { status: 'cancelled' } + | { status: 'paused' } + | { status: 'error' } + | { status: 'wrong-account'; signedInAs: string } + +export type SecretsOutcomeState = Extract< + SecretsState, + { status: 'stored' | 'already-stored' | 'expired' | 'cancelled' | 'paused' | 'error' } +> diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.test.ts b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.test.ts new file mode 100644 index 00000000000..7ba13370581 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { SecretRequest } from './McpSecrets.types' +import { + getOverwriteWarning, + getSecretHelperText, + getSecretPrefixWarning, + getSecretsAnnouncement, + getSecretsCopy, +} from './McpSecrets.utils' + +const request: SecretRequest = { + tool: 'create_edge_function_secret', + ref: 'abcdefghijklmnopqrst', + project: 'billing-staging', + account: 'ops@example.com', + keyName: 'RESEND_API_KEY', + providerHint: { name: 'Resend', prefix: 're_', dashboardUrl: 'https://resend.com/api-keys' }, +} + +const unrecognizedRequest: SecretRequest = { + ...request, + keyName: 'MY_WEBHOOK_TOKEN', + providerHint: undefined, +} + +describe('getSecretsCopy', () => { + it('interpolates the key name and project into the stored state', () => { + const copy = getSecretsCopy({ status: 'stored', request, timedOut: false }) + + expect(copy.title).toBe('Key stored') + expect(copy.subtitle).toBe('RESEND_API_KEY is saved for billing-staging.') + }) + + it('names the generic client, because v1 never learns which one sent the user', () => { + const copy = getSecretsCopy({ status: 'stored', request, timedOut: false }) + + expect(copy.calloutBody).toBe( + `Go back to your AI client and choose "I've completed it" to finish the tool call.` + ) + }) + + it('sends the user back to the agent instead of the client on timeout', () => { + const copy = getSecretsCopy({ status: 'stored', request, timedOut: true }) + + expect(copy.subtitle).toContain('so it may have stopped listening') + expect(copy.calloutBody).toBe( + 'Ask your agent to store the key again. It will find the saved key and finish without sending you back here.' + ) + }) + + it('says nothing was stored on the generic error, and offers both ways out', () => { + const copy = getSecretsCopy({ status: 'error' }) + + expect(copy.title).toBe("Couldn't complete this request") + expect(copy.subtitle).toBe('Nothing was stored.') + expect(copy.calloutBody).toBe( + 'Ask your agent to run the tool again, or set the key in Edge Functions secrets instead.' + ) + }) + + it('never leaks a failure reason the user cannot act on', () => { + const copy = getSecretsCopy({ status: 'error' }) + + expect(Object.values(copy).join(' ')).not.toMatch(/403|forbidden|permission|error code/i) + }) + + it('gives every terminal state a recovery path and never claims the key was checked', () => { + const states = [ + { status: 'stored', request, timedOut: false }, + { status: 'stored', request, timedOut: true }, + { status: 'already-stored', request }, + { status: 'expired' }, + { status: 'cancelled' }, + { status: 'paused' }, + { status: 'error' }, + ] as const + + for (const state of states) { + const copy = getSecretsCopy(state) + + expect(copy.calloutTitle).toBe('Next step') + expect(copy.calloutBody.length).toBeGreaterThan(0) + expect(copy.footer.length).toBeGreaterThan(0) + expect(Object.values(copy).join(' ')).not.toMatch(/valid|verified|works? correctly/i) + } + }) + + it('never leaks a provider into a request that does not name one', () => { + const rendered = [ + getSecretsCopy({ status: 'stored', request: unrecognizedRequest, timedOut: false }), + getSecretsCopy({ status: 'already-stored', request: unrecognizedRequest }), + getSecretsCopy({ status: 'expired' }), + getSecretsCopy({ status: 'cancelled' }), + getSecretsCopy({ status: 'paused' }), + getSecretsCopy({ status: 'error' }), + ] + .flatMap((copy) => Object.values(copy)) + .join(' ') + + expect(rendered).not.toMatch(/openai|anthropic|resend|stripe/i) + }) +}) + +describe('getSecretHelperText', () => { + it('names the project the secret is scoped to', () => { + expect(getSecretHelperText('my-project')).toBe( + 'Stored encrypted for my-project. Anyone with write access to this project can use it. Remove it any time from Edge Functions secrets.' + ) + }) +}) + +describe('getOverwriteWarning', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-04T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('stays quiet when the name is not in use', () => { + expect(getOverwriteWarning(request)).toBeUndefined() + }) + + it('names the key and how long ago it was written', () => { + const warning = getOverwriteWarning({ + ...request, + existingSecret: { updatedAt: '2026-09-04T11:58:00Z' }, + }) + + expect(warning).toBe( + 'RESEND_API_KEY already exists — updated 2 minutes ago. Storing will replace it.' + ) + }) + + it('reads at seconds and hours granularity too', () => { + expect( + getOverwriteWarning({ ...request, existingSecret: { updatedAt: '2026-09-04T11:59:55Z' } }) + ).toContain('updated a few seconds ago') + expect( + getOverwriteWarning({ ...request, existingSecret: { updatedAt: '2026-09-04T09:00:00Z' } }) + ).toContain('updated 3 hours ago') + }) + + it('accepts unix microseconds, which the secrets endpoint also returns', () => { + const twoMinutesAgoInMicros = String(new Date('2026-09-04T11:58:00Z').getTime() * 1000) + + expect(twoMinutesAgoInMicros).toHaveLength(16) + expect( + getOverwriteWarning({ ...request, existingSecret: { updatedAt: twoMinutesAgoInMicros } }) + ).toContain('updated 2 minutes ago') + }) + + it('still warns when the platform gave no timestamp', () => { + expect(getOverwriteWarning({ ...request, existingSecret: { updatedAt: undefined } })).toBe( + 'RESEND_API_KEY already exists. Storing will replace it.' + ) + expect(getOverwriteWarning({ ...request, existingSecret: { updatedAt: 'not a date' } })).toBe( + 'RESEND_API_KEY already exists. Storing will replace it.' + ) + }) +}) + +describe('getSecretPrefixWarning', () => { + it('stays quiet while the field is empty', () => { + expect(getSecretPrefixWarning('', request.providerHint)).toBeUndefined() + }) + + it('stays quiet when the value matches the hinted prefix', () => { + expect(getSecretPrefixWarning('re_abc123', request.providerHint)).toBeUndefined() + }) + + it('stays quiet when the request carries no provider hint', () => { + expect(getSecretPrefixWarning('anything', undefined)).toBeUndefined() + }) + + it('stays quiet when the hint has no prefix to compare against', () => { + expect(getSecretPrefixWarning('anything', { name: 'Acme' })).toBeUndefined() + }) + + it('softly flags a mismatch without blocking the store', () => { + expect(getSecretPrefixWarning('sk-abc123', request.providerHint)).toBe( + 'Resend keys usually start with re_. You can still save this one.' + ) + }) +}) + +describe('getSecretsAnnouncement', () => { + it('is empty before anything has resolved, so the region mounts silent', () => { + // A live region never announces content that was present when it mounted. + // Rendering nothing first is what makes the first real state a change. + expect(getSecretsAnnouncement(undefined)).toBe('') + }) + + it('says what is happening while the queries resolve', () => { + expect(getSecretsAnnouncement({ status: 'loading' })).toBe('Loading request details') + }) + + it('names the key and project once the form is ready', () => { + expect(getSecretsAnnouncement({ status: 'form', request })).toBe( + 'Ready to save RESEND_API_KEY for billing-staging' + ) + }) + + it('announces the outcome, which the card heading alone never reads out', () => { + expect(getSecretsAnnouncement({ status: 'stored', request, timedOut: false })).toBe( + 'Key stored. RESEND_API_KEY is saved for billing-staging.' + ) + expect(getSecretsAnnouncement({ status: 'error' })).toBe( + "Couldn't complete this request. Nothing was stored." + ) + }) + + it('never repeats the callout, which Admonition already announces via role="alert"', () => { + const states = [ + { status: 'stored', request, timedOut: false }, + { status: 'already-stored', request }, + { status: 'expired' }, + { status: 'cancelled' }, + { status: 'paused' }, + { status: 'error' }, + ] as const + + for (const state of states) { + expect(getSecretsAnnouncement(state)).not.toContain(getSecretsCopy(state).calloutBody) + } + }) + + it('gives every state something to announce', () => { + const states = [ + { status: 'loading' }, + { status: 'form', request }, + { status: 'wrong-account', signedInAs: 'ops@example.com' }, + { status: 'stored', request, timedOut: true }, + { status: 'already-stored', request }, + { status: 'expired' }, + { status: 'cancelled' }, + { status: 'paused' }, + { status: 'error' }, + ] as const + + for (const state of states) { + expect(getSecretsAnnouncement(state).length).toBeGreaterThan(0) + } + }) + + it('does not leak the signed-in account into the wrong-account announcement', () => { + expect( + getSecretsAnnouncement({ status: 'wrong-account', signedInAs: 'ops@example.com' }) + ).not.toContain('ops@example.com') + }) +}) diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.ts b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.ts new file mode 100644 index 00000000000..796fa0434b2 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.ts @@ -0,0 +1,146 @@ +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import utc from 'dayjs/plugin/utc' + +import type { InterstitialTerminalCopy } from '../InterstitialTerminalScreen' +import { UNKNOWN_CLIENT_LABEL } from './McpSecrets.constants' +import type { + SecretProviderHint, + SecretRequest, + SecretsOutcomeState, + SecretsState, +} from './McpSecrets.types' + +dayjs.extend(relativeTime) +dayjs.extend(utc) + +const RETURN_TO_CLIENT_STEP = `Go back to ${UNKNOWN_CLIENT_LABEL} and choose "I've completed it" to finish the tool call.` + +const CLOSE_TAB_FOOTER = 'You can close this tab.' + +const UNVERIFIED_KEY_FOOTER = + "Supabase doesn't verify keys. You can view or replace this one in Edge Functions secrets." + +export function getSecretsCopy(state: SecretsOutcomeState): InterstitialTerminalCopy { + switch (state.status) { + case 'stored': { + const { keyName, project } = state.request + const savedSentence = `${keyName} is saved for ${project}.` + + if (state.timedOut) { + return { + title: 'Key stored', + subtitle: `${savedSentence} This took longer than your client waits, so it may have stopped listening.`, + calloutTitle: 'Next step', + calloutBody: + 'Ask your agent to store the key again. It will find the saved key and finish without sending you back here.', + footer: UNVERIFIED_KEY_FOOTER, + } + } + + return { + title: 'Key stored', + subtitle: savedSentence, + calloutTitle: 'Next step', + calloutBody: RETURN_TO_CLIENT_STEP, + footer: UNVERIFIED_KEY_FOOTER, + } + } + + case 'already-stored': + return { + title: 'This key is already stored', + subtitle: `${state.request.keyName} was saved for ${state.request.project}. Nothing further to do here.`, + calloutTitle: 'Next step', + calloutBody: RETURN_TO_CLIENT_STEP, + footer: CLOSE_TAB_FOOTER, + } + + case 'expired': + return { + title: 'This link has expired', + subtitle: 'Nothing was stored. Your key is still safe where you copied it from.', + calloutTitle: 'Next step', + calloutBody: "Ask your agent to store your API key again. You'll get a fresh link.", + footer: CLOSE_TAB_FOOTER, + } + + case 'cancelled': + return { + title: 'This request was cancelled', + subtitle: 'Nothing was stored.', + calloutTitle: 'Next step', + calloutBody: 'Ask your agent to run the tool again if you still need to store the key.', + footer: CLOSE_TAB_FOOTER, + } + + case 'paused': + return { + title: 'Storing keys is paused', + subtitle: 'Supabase has turned this off for now. Nothing was stored.', + calloutTitle: 'Next step', + calloutBody: 'Try again later, or set the key in Edge Functions secrets instead.', + footer: CLOSE_TAB_FOOTER, + } + + case 'error': + return { + title: "Couldn't complete this request", + subtitle: 'Nothing was stored.', + calloutTitle: 'Next step', + calloutBody: + 'Ask your agent to run the tool again, or set the key in Edge Functions secrets instead.', + footer: CLOSE_TAB_FOOTER, + } + } +} + +export function getSecretsAnnouncement(state: SecretsState | undefined): string { + if (state === undefined) return '' + + switch (state.status) { + case 'loading': + return 'Loading request details' + case 'form': + return `Ready to save ${state.request.keyName} for ${state.request.project}` + case 'wrong-account': + return 'This account cannot access the request' + default: { + const { title, subtitle } = getSecretsCopy(state) + return `${title}. ${subtitle}` + } + } +} + +export function getSecretHelperText(project: string) { + return `Stored encrypted for ${project}. Anyone with write access to this project can use it. Remove it any time from Edge Functions secrets.` +} + +export function getOverwriteWarning(request: SecretRequest) { + const { existingSecret, keyName } = request + if (existingSecret === undefined) return undefined + + const updatedAt = existingSecret.updatedAt + const age = updatedAt === undefined ? undefined : formatSecretAge(updatedAt) + + return age === undefined + ? `${keyName} already exists. Storing will replace it.` + : `${keyName} already exists — updated ${age} ago. Storing will replace it.` +} + +function formatSecretAge(updatedAt: string) { + const isUnixMicro = !Number.isNaN(Number(updatedAt)) && updatedAt.length === 16 + const parsed = isUnixMicro ? dayjs.unix(Number(updatedAt) / 1000 / 1000) : dayjs.utc(updatedAt) + + return parsed.isValid() ? parsed.fromNow(true) : undefined +} + +export function getSecretPrefixWarning( + value: string, + providerHint: SecretProviderHint | undefined +) { + const prefix = providerHint?.prefix + if (!prefix || value.length === 0 || value.startsWith(prefix)) return undefined + + return `${providerHint.name} keys usually start with ${prefix}. You can still save this one.` +} diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecretsCard.test.tsx b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsCard.test.tsx new file mode 100644 index 00000000000..9337338c95c --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsCard.test.tsx @@ -0,0 +1,157 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import type { SecretRequest } from './McpSecrets.types' +import { McpSecretsCard } from './McpSecretsCard' +import { customRender } from '@/tests/lib/custom-render' + +const request: SecretRequest = { + tool: 'create_edge_function_secret', + ref: 'aaaaaaaaaaaaaaaaaaaa', + project: 'acme-production', + account: 'ops@example.com', + keyName: 'OPENAI_API_KEY', +} + +const noop = () => {} + +const renderCard = (state: Parameters[0]['state']) => + customRender( + + ) + +const secretField = () => screen.getByLabelText('Secret value') + +describe('McpSecretsCard', () => { + it('does not carry a typed secret across a change of request', async () => { + const user = userEvent.setup() + const { rerender } = renderCard({ status: 'form', request }) + + await user.type(secretField(), 'sk-for-the-first-request') + await user.click(screen.getByRole('button', { name: 'Show secret value' })) + expect(secretField().value).toBe('sk-for-the-first-request') + expect(secretField().type).toBe('text') + + rerender( + + ) + + await waitFor(() => expect(screen.getByDisplayValue('RESEND_API_KEY')).toBeInTheDocument()) + expect(secretField().value).toBe('') + expect(secretField().type).toBe('password') + }) + + it('resets when only the project changes, since a ref can move under one name', async () => { + const user = userEvent.setup() + const { rerender } = renderCard({ status: 'form', request }) + + await user.type(secretField(), 'sk-for-project-a') + + rerender( + + ) + + await waitFor(() => expect(secretField().value).toBe('')) + }) + + it('keeps what the user typed when the request is unchanged', async () => { + const user = userEvent.setup() + const { rerender } = renderCard({ status: 'form', request }) + + await user.type(secretField(), 'sk-still-being-typed') + + rerender( + + ) + + expect(await screen.findByText(/already exists/)).toBeInTheDocument() + expect(secretField().value).toBe('sk-still-being-typed') + }) + + it('keeps the prefix-warning live region mounted before there is a warning to announce', async () => { + const user = userEvent.setup() + renderCard({ + status: 'form', + request: { ...request, providerHint: { name: 'OpenAI', prefix: 'sk-' } }, + }) + + const liveRegion = screen.getByRole('status') + expect(liveRegion).toHaveAttribute('aria-live', 'polite') + expect(liveRegion).toBeEmptyDOMElement() + + await user.type(secretField(), 'not-a-real-prefix') + + await waitFor(() => expect(liveRegion).toHaveTextContent(/usually start with sk-/)) + expect(screen.getByRole('status')).toBe(liveRegion) + }) + + it('blocks an empty submit and explains why, without calling onSave', async () => { + const onSave = vi.fn() + customRender( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(await screen.findByText('Enter the key value')).toBeInTheDocument() + expect(secretField()).toHaveAttribute('aria-invalid', 'true') + expect(secretField().getAttribute('aria-describedby')).toContain('form-item-message') + expect(onSave).not.toHaveBeenCalled() + }) + + it('submits the value exactly as typed, with no trimming', async () => { + const onSave = vi.fn() + const user = userEvent.setup() + customRender( + + ) + + await user.type(secretField(), ' sk-padded ') + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSave).toHaveBeenCalledWith(' sk-padded ')) + }) +}) diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecretsCard.tsx b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsCard.tsx new file mode 100644 index 00000000000..1f828d288f0 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsCard.tsx @@ -0,0 +1,42 @@ +import { InterstitialTerminalScreen } from '../InterstitialTerminalScreen' +import type { SecretsState } from './McpSecrets.types' +import { getSecretsCopy } from './McpSecrets.utils' +import { McpSecretsForm } from './McpSecretsForm' +import { McpSecretsSkeleton } from './McpSecretsSkeleton' +import { McpSecretsWrongAccount } from './McpSecretsWrongAccount' + +export const McpSecretsCard = ({ + state, + isSaving, + onSave, + onCancel, + onSwitchAccount, +}: { + state: SecretsState + isSaving: boolean + onSave: (secret: string) => void + onCancel: () => void + onSwitchAccount: () => void +}) => { + if (state.status === 'loading') return + + if (state.status === 'form') { + return ( + + ) + } + + if (state.status === 'wrong-account') { + return ( + + ) + } + + return +} diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecretsDetails.tsx b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsDetails.tsx new file mode 100644 index 00000000000..e65bbf7b4a7 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsDetails.tsx @@ -0,0 +1,41 @@ +import { cn, Skeleton } from 'ui' + +import type { SecretRequest } from './McpSecrets.types' + +const DETAIL_ROW_COUNT = 3 + +const DetailRow = ({ + label, + value, + isMono = false, +}: { + label: string + value: string + isMono?: boolean +}) => ( +
+ {label} + + {value} + +
+) + +export const McpSecretsDetails = ({ request }: { request: SecretRequest }) => ( +
+ + + +
+) + +export const McpSecretsDetailsSkeleton = () => ( +
+ {Array.from({ length: DETAIL_ROW_COUNT }).map((_, index) => ( +
+ + +
+ ))} +
+) diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecretsForm.tsx b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsForm.tsx new file mode 100644 index 00000000000..834a506c478 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsForm.tsx @@ -0,0 +1,186 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Eye, EyeOff, Lock } from 'lucide-react' +import { useState } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import { + Button, + cn, + Form, + FormField, + FormInputGroupInput, + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + Separator, +} from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import z from 'zod' + +import { InterstitialShell } from '../InterstitialShell' +import type { SecretRequest } from './McpSecrets.types' +import { + getOverwriteWarning, + getSecretHelperText, + getSecretPrefixWarning, +} from './McpSecrets.utils' +import { McpSecretsDetails } from './McpSecretsDetails' + +const FORM_ID = 'mcp-secrets-form' +const KEY_NAME_FIELD_ID = 'mcp-secrets-key-name' + +const FormSchema = z.object({ + secret: z.string().min(1, 'Enter the key value'), +}) + +type FormValues = z.infer + +const defaultValues: FormValues = { secret: '' } + +export const McpSecretsForm = ({ + request, + isSaving, + onSave, + onCancel, +}: { + request: SecretRequest + isSaving: boolean + onSave: (secret: string) => void + onCancel: () => void +}) => { + const [isRevealed, setIsRevealed] = useState(false) + + const form = useForm({ resolver: zodResolver(FormSchema), defaultValues }) + const secret = useWatch({ control: form.control, name: 'secret' }) + + const { providerHint } = request + const prefixWarning = getSecretPrefixWarning(secret, providerHint) + const overwriteWarning = getOverwriteWarning(request) + + return ( + + + +
+ onSave(values.secret))} + className="flex flex-col gap-6" + > + + + + + + + + + +
+ ( + + + + + : } + onClick={() => setIsRevealed(!isRevealed)} + /> + + + + )} + /> +

+ {prefixWarning} +

+
+ +
+ + {overwriteWarning && ( + + )} +
+ +
+ + +
+
+ + + {providerHint && ( + <> + +

+ {`Don't have your key? Create one in the `} + {providerHint.dashboardUrl ? ( + + {providerHint.name} dashboard + + ) : ( + `${providerHint.name} dashboard` + )} + . +

+ + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecretsSkeleton.tsx b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsSkeleton.tsx new file mode 100644 index 00000000000..eb3acd20d9b --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsSkeleton.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from 'ui' + +import { InterstitialShellSkeleton } from '../InterstitialShell' +import { McpSecretsDetailsSkeleton } from './McpSecretsDetails' + +export const McpSecretsSkeleton = () => ( + + +
+
+ + +
+
+ + +
+
+ +
+) diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecretsWrongAccount.tsx b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsWrongAccount.tsx new file mode 100644 index 00000000000..3fec66f87bd --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsWrongAccount.tsx @@ -0,0 +1,34 @@ +import { Button, Separator } from 'ui' + +import { InterstitialFooter, InterstitialShell } from '../InterstitialShell' +import { InterstitialAccountRow } from '@/components/layouts/InterstitialLayout' + +export const McpSecretsWrongAccount = ({ + signedInAs, + onSwitchAccount, +}: { + signedInAs: string + onSwitchAccount: () => void +}) => ( + + + +
+ +

+ Signing in again will probably expire this request. +

+
+ + + + + After switching, ask your agent to run the tool again. Nothing has been stored. + +
+) diff --git a/apps/studio/components/interfaces/MCP/Secrets/useSecretRequest.test.ts b/apps/studio/components/interfaces/MCP/Secrets/useSecretRequest.test.ts new file mode 100644 index 00000000000..0039402e9d1 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/useSecretRequest.test.ts @@ -0,0 +1,141 @@ +import { act, waitFor } from '@testing-library/react' +import { delay, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' + +import type { SecretsParams } from './McpSecrets.params' +import { useSecretRequest } from './useSecretRequest' +import type { components } from '@/data/api' +import { customRenderHook } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' + +const REF_A = 'aaaaaaaaaaaaaaaaaaaa' +const NAME_A = 'OPENAI_API_KEY' +const NAME_B = 'RESEND_API_KEY' + +const paramsFor = (ref: string, name: string): SecretsParams => ({ + ref, + name, + dev: { state: undefined }, +}) + +const WRITE_DELAY_MS = 120 + +const PROJECT: components['schemas']['ProjectDetailResponse'] = { + cloud_provider: 'AWS', + db_host: 'db.example.supabase.co', + high_availability: false, + id: 1, + inserted_at: '2026-01-01T00:00:00Z', + integration_source: null, + is_branch_enabled: false, + is_physical_backups_enabled: false, + name: 'acme-production', + organization_id: 1, + ref: REF_A, + region: 'us-east-1', + restUrl: 'https://example.supabase.co/rest/v1/', + status: 'ACTIVE_HEALTHY', + subscription_id: 'sub_1', + updated_at: '2026-01-01T00:00:00Z', +} + +let writtenNames: string[] = [] + +beforeEach(() => { + writtenNames = [] + + addAPIMock({ method: 'get', path: '/platform/projects/:ref', response: PROJECT }) + addAPIMock({ method: 'get', path: '/v1/projects/:ref/secrets', response: [] }) + + addAPIMock({ + method: 'post', + path: '/v1/projects/:ref/secrets', + response: async ({ request }) => { + const body = (await request.json()) as { name: string }[] + writtenNames.push(...body.map((secret) => secret.name)) + await delay(WRITE_DELAY_MS) + return HttpResponse.json([]) + }, + }) +}) + +describe('useSecretRequest', () => { + it('reports a stored outcome for the request that was actually written', async () => { + const { result } = customRenderHook(() => useSecretRequest(paramsFor(REF_A, NAME_A))) + + await waitFor(() => expect(result.current.state.status).toBe('form')) + + act(() => result.current.saveSecret('sk-value')) + + await waitFor(() => expect(result.current.state.status).toBe('stored')) + expect(writtenNames).toEqual([NAME_A]) + }) + + it('does not offer the form when the existing secrets cannot be read', async () => { + // Without the secrets list there is no way to know an overwrite is about to + // happen, so the form would be missing its warning. + addAPIMock({ + method: 'get', + path: '/v1/projects/:ref/secrets', + response: () => HttpResponse.json({ message: 'nope' }, { status: 500 }), + }) + + const { result } = customRenderHook(() => useSecretRequest(paramsFor(REF_A, NAME_A))) + + await waitFor(() => expect(result.current.state.status).toBe('error')) + expect(writtenNames).toEqual([]) + }) + + it('does not attribute request A’s settled write to request B', async () => { + let params = paramsFor(REF_A, NAME_A) + const { result, rerender } = customRenderHook(() => useSecretRequest(params)) + + await waitFor(() => expect(result.current.state.status).toBe('form')) + + act(() => result.current.saveSecret('sk-belongs-to-a')) + params = paramsFor(REF_A, NAME_B) + rerender() + + await waitFor(() => { + const { state } = result.current + expect(state.status === 'form' && state.request.keyName).toBe(NAME_B) + }) + + await new Promise((resolve) => setTimeout(resolve, WRITE_DELAY_MS * 3)) + + expect(result.current.state.status).toBe('form') + expect(writtenNames).toEqual([NAME_A]) + }) + + it('does not carry a cancellation from request A onto request B', async () => { + let params = paramsFor(REF_A, NAME_A) + const { result, rerender } = customRenderHook(() => useSecretRequest(params)) + + await waitFor(() => expect(result.current.state.status).toBe('form')) + + act(() => result.current.cancelRequest()) + expect(result.current.state.status).toBe('cancelled') + + params = paramsFor(REF_A, NAME_B) + rerender() + + await waitFor(() => expect(result.current.state.status).toBe('form')) + }) + + it('still shows A’s outcome if the user navigates back to A', async () => { + let params = paramsFor(REF_A, NAME_A) + const { result, rerender } = customRenderHook(() => useSecretRequest(params)) + + await waitFor(() => expect(result.current.state.status).toBe('form')) + + act(() => result.current.cancelRequest()) + params = paramsFor(REF_A, NAME_B) + rerender() + await waitFor(() => expect(result.current.state.status).toBe('form')) + + params = paramsFor(REF_A, NAME_A) + rerender() + + await waitFor(() => expect(result.current.state.status).toBe('cancelled')) + }) +}) diff --git a/apps/studio/components/interfaces/MCP/Secrets/useSecretRequest.ts b/apps/studio/components/interfaces/MCP/Secrets/useSecretRequest.ts new file mode 100644 index 00000000000..8c485e8e1e8 --- /dev/null +++ b/apps/studio/components/interfaces/MCP/Secrets/useSecretRequest.ts @@ -0,0 +1,144 @@ +import { useSession } from 'common' +import { useCallback, useMemo, useState } from 'react' + +import { SECRETS_TOOL_NAME } from './McpSecrets.constants' +import type { DevSecretsState, SecretsParams } from './McpSecrets.params' +import { getProviderHint } from './McpSecrets.providers' +import type { SecretRequest, SecretsState } from './McpSecrets.types' +import { useProjectDetailQuery } from '@/data/projects/project-detail-query' +import { + useSecretsCreateMutation, + type SecretsCreateVariables, +} from '@/data/secrets/secrets-create-mutation' +import { useSecretsQuery } from '@/data/secrets/secrets-query' + +type RecordedOutcome = { + ref: string | undefined + name: string | undefined + status: 'stored' | 'cancelled' | 'error' +} + +function recordOutcome( + variables: SecretsCreateVariables, + status: RecordedOutcome['status'] +): RecordedOutcome { + return { ref: variables.projectRef, name: variables.secrets[0]?.name, status } +} + +export function useSecretRequest(params: SecretsParams) { + const { ref, name } = params + const devState = params.dev.state + + const session = useSession() + const account = session?.user?.email ?? '' + + const [outcome, setOutcome] = useState(undefined) + + const isLinkResolvable = ref !== undefined && name !== undefined + + const project = useProjectDetailQuery({ ref }, { enabled: isLinkResolvable }) + const secrets = useSecretsQuery({ projectRef: ref }, { enabled: isLinkResolvable }) + + const { mutate, isPending: isSaving } = useSecretsCreateMutation({ + onSuccess: (_data, variables) => setOutcome(recordOutcome(variables, 'stored')), + onError: (_error, variables) => setOutcome(recordOutcome(variables, 'error')), + }) + + const request: SecretRequest | undefined = useMemo(() => { + if (ref === undefined || name === undefined || project.data === undefined) return undefined + + const existing = secrets.data?.find((secret) => secret.name === name) + + return { + tool: SECRETS_TOOL_NAME, + ref, + project: project.data.name, + account, + keyName: name, + providerHint: getProviderHint(name), + existingSecret: existing === undefined ? undefined : { updatedAt: existing.updated_at }, + } + }, [account, name, project.data, ref, secrets.data]) + + const state = useMemo(() => { + const overridden = + devState === undefined ? undefined : resolveDevState(devState, request, account) + if (overridden !== undefined) return overridden + + if (!isLinkResolvable) return { status: 'expired' } + + const activeOutcome = + outcome?.ref === ref && outcome?.name === name ? outcome.status : undefined + + if (activeOutcome === 'cancelled') return { status: 'cancelled' } + if (activeOutcome === 'error') return { status: 'error' } + if (activeOutcome === 'stored' && request !== undefined) { + return { status: 'stored', request, timedOut: false } + } + + if (project.isError || secrets.isError) return { status: 'error' } + + if (project.isPending || secrets.isPending || request === undefined) { + return { status: 'loading' } + } + + return { status: 'form', request } + }, [ + account, + devState, + isLinkResolvable, + name, + outcome, + project.isError, + ref, + project.isPending, + request, + secrets.isError, + secrets.isPending, + ]) + + const saveSecret = useCallback( + (secret: string) => { + if (ref === undefined || name === undefined || isSaving) return + + mutate({ projectRef: ref, secrets: [{ name, value: secret }] }) + }, + [isSaving, mutate, name, ref] + ) + + const cancelRequest = useCallback( + () => setOutcome({ ref, name, status: 'cancelled' }), + [name, ref] + ) + + return { state, isSaving, saveSecret, cancelRequest } +} + +function resolveDevState( + devState: DevSecretsState, + request: SecretRequest | undefined, + account: string +): SecretsState | undefined { + switch (devState) { + case 'loading': + return { status: 'loading' } + case 'expired': + return { status: 'expired' } + case 'cancelled': + return { status: 'cancelled' } + case 'paused': + return { status: 'paused' } + case 'error': + return { status: 'error' } + case 'wrong-account': + return { status: 'wrong-account', signedInAs: account } + case 'form': + return request && { status: 'form', request } + case 'stored': + return request && { status: 'stored', request, timedOut: false } + case 'stored-timeout': + return request && { status: 'stored', request, timedOut: true } + case 'already-stored': + return request && { status: 'already-stored', request } + } +} diff --git a/apps/studio/components/layouts/InterstitialLayout.tsx b/apps/studio/components/layouts/InterstitialLayout.tsx index d7597dd32a4..a2b54fbc78b 100644 --- a/apps/studio/components/layouts/InterstitialLayout.tsx +++ b/apps/studio/components/layouts/InterstitialLayout.tsx @@ -1,4 +1,4 @@ -import { motion } from 'framer-motion' +import { motion, useReducedMotion } from 'framer-motion' import { ArrowRightLeft } from 'lucide-react' import type { PropsWithChildren, ReactNode } from 'react' import { Card, CardContent, CardHeader, cn } from 'ui' @@ -41,6 +41,7 @@ export const InterstitialLayout = ({ descriptionClassName, children, }: PropsWithChildren) => { + const shouldReduceMotion = useReducedMotion() const TitleElement = typeof title === 'string' ? 'h1' : 'div' const DescriptionElement = typeof description === 'string' ? 'p' : 'div' @@ -68,7 +69,7 @@ export const InterstitialLayout = ({ const card = ( @@ -190,7 +191,7 @@ export const InterstitialAccountRow = ({ >
diff --git a/apps/studio/lib/gotrue.test.ts b/apps/studio/lib/gotrue.test.ts index dc4dca400d0..298a9a84221 100644 --- a/apps/studio/lib/gotrue.test.ts +++ b/apps/studio/lib/gotrue.test.ts @@ -4,6 +4,7 @@ import { buildSignUpReturnPath, DEFAULT_FALLBACK_PATH, DEFAULT_SIGNUP_RETURN_PATH, + getReturnToPath, getSignUpReturnTo, validateReturnTo, } from './gotrue' @@ -104,6 +105,26 @@ describe('buildSignUpReturnPath', () => { }) }) +describe('getReturnToPath', () => { + it('rebuilds a nested returnTo path with the params that rode alongside it', () => { + // @ts-ignore + delete window.location + // @ts-ignore + window.location = { search: '?ref=abc&name=OPENAI_API_KEY&returnTo=%2Fmcp%2Fsecrets' } + + expect(getReturnToPath()).toBe('/mcp/secrets?ref=abc&name=OPENAI_API_KEY') + }) + + it('accepts a multi-segment returnTo rather than falling back', () => { + // @ts-ignore + delete window.location + // @ts-ignore + window.location = { search: '?returnTo=%2Fmcp%2Fsecrets' } + + expect(getReturnToPath()).toBe('/mcp/secrets') + }) +}) + describe('validateReturnTo', () => { const fallback = DEFAULT_FALLBACK_PATH diff --git a/apps/studio/pages/mcp/secrets.tsx b/apps/studio/pages/mcp/secrets.tsx new file mode 100644 index 00000000000..3570fa90557 --- /dev/null +++ b/apps/studio/pages/mcp/secrets.tsx @@ -0,0 +1,21 @@ +import Head from 'next/head' + +import { McpSecrets } from '@/components/interfaces/MCP/Secrets/McpSecrets' +import { withAuth } from '@/hooks/misc/withAuth' +import { buildStudioPageTitle } from '@/lib/page-title' +import type { NextPageWithLayout } from '@/types' + +const PAGE_TITLE = buildStudioPageTitle({ section: 'Store an API key', brand: 'Supabase' }) + +const McpSecretsPage: NextPageWithLayout = () => { + return ( + <> + + {PAGE_TITLE} + + + + ) +} + +export default withAuth(McpSecretsPage) diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 5525691e471..e79427123ec 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as ProjectChar91_Char93RouteImport } from './routes/project.[_]' import { Route as ProjectRefRouteImport } from './routes/project/$ref' import { Route as OrgChar91_Char93RouteImport } from './routes/org.[_]' import { Route as NewSlugRouteImport } from './routes/new/$slug' +import { Route as McpSecretsRouteImport } from './routes/mcp/secrets' import { Route as IntegrationsVercelRouteImport } from './routes/integrations/vercel' import { Route as ApiStatusOverrideRouteImport } from './routes/api/status-override' import { Route as ApiScopedAccessTokenPermissionsRouteImport } from './routes/api/scoped-access-token-permissions' @@ -403,6 +404,11 @@ const NewSlugRoute = NewSlugRouteImport.update({ path: '/new/$slug', getParentRoute: () => rootRouteImport, } as any) +const McpSecretsRoute = McpSecretsRouteImport.update({ + id: '/mcp/secrets', + path: '/mcp/secrets', + getParentRoute: () => rootRouteImport, +} as any) const IntegrationsVercelRoute = IntegrationsVercelRouteImport.update({ id: '/integrations/vercel', path: '/integrations/vercel', @@ -2149,6 +2155,7 @@ export interface FileRoutesByFullPath { '/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute '/api/status-override': typeof ApiStatusOverrideRoute '/integrations/vercel': typeof IntegrationsVercelRouteWithChildren + '/mcp/secrets': typeof McpSecretsRoute '/new/$slug': typeof NewSlugRoute '/org/_': typeof OrgChar91_Char93RouteWithChildren '/project/$ref': typeof ProjectRefRouteWithChildren @@ -2467,6 +2474,7 @@ export interface FileRoutesByTo { '/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute '/api/status-override': typeof ApiStatusOverrideRoute '/integrations/vercel': typeof IntegrationsVercelRouteWithChildren + '/mcp/secrets': typeof McpSecretsRoute '/new/$slug': typeof NewSlugRoute '/org/_': typeof OrgChar91_Char93RouteWithChildren '/project/_': typeof ProjectChar91_Char93RouteWithChildren @@ -2776,6 +2784,7 @@ export interface FileRoutesById { '/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute '/api/status-override': typeof ApiStatusOverrideRoute '/integrations/vercel': typeof IntegrationsVercelRouteWithChildren + '/mcp/secrets': typeof McpSecretsRoute '/new/$slug': typeof NewSlugRoute '/org/_': typeof OrgChar91_Char93RouteWithChildren '/project/$ref': typeof ProjectRefRouteWithChildren @@ -3097,6 +3106,7 @@ export interface FileRouteTypes { | '/api/scoped-access-token-permissions' | '/api/status-override' | '/integrations/vercel' + | '/mcp/secrets' | '/new/$slug' | '/org/_' | '/project/$ref' @@ -3415,6 +3425,7 @@ export interface FileRouteTypes { | '/api/scoped-access-token-permissions' | '/api/status-override' | '/integrations/vercel' + | '/mcp/secrets' | '/new/$slug' | '/org/_' | '/project/_' @@ -3723,6 +3734,7 @@ export interface FileRouteTypes { | '/api/scoped-access-token-permissions' | '/api/status-override' | '/integrations/vercel' + | '/mcp/secrets' | '/new/$slug' | '/org/_' | '/project/$ref' @@ -4034,6 +4046,7 @@ export interface RootRouteChildren { ApiScopedAccessTokenPermissionsRoute: typeof ApiScopedAccessTokenPermissionsRoute ApiStatusOverrideRoute: typeof ApiStatusOverrideRoute IntegrationsVercelRoute: typeof IntegrationsVercelRouteWithChildren + McpSecretsRoute: typeof McpSecretsRoute NewSlugRoute: typeof NewSlugRoute OrgChar91_Char93Route: typeof OrgChar91_Char93RouteWithChildren ProjectRefRoute: typeof ProjectRefRouteWithChildren @@ -4238,6 +4251,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof NewSlugRouteImport parentRoute: typeof rootRouteImport } + '/mcp/secrets': { + id: '/mcp/secrets' + path: '/mcp/secrets' + fullPath: '/mcp/secrets' + preLoaderRoute: typeof McpSecretsRouteImport + parentRoute: typeof rootRouteImport + } '/integrations/vercel': { id: '/integrations/vercel' path: '/integrations/vercel' @@ -7115,6 +7135,7 @@ const rootRouteChildren: RootRouteChildren = { ApiScopedAccessTokenPermissionsRoute: ApiScopedAccessTokenPermissionsRoute, ApiStatusOverrideRoute: ApiStatusOverrideRoute, IntegrationsVercelRoute: IntegrationsVercelRouteWithChildren, + McpSecretsRoute: McpSecretsRoute, NewSlugRoute: NewSlugRoute, OrgChar91_Char93Route: OrgChar91_Char93RouteWithChildren, ProjectRefRoute: ProjectRefRouteWithChildren, diff --git a/apps/studio/routes/mcp/secrets.tsx b/apps/studio/routes/mcp/secrets.tsx new file mode 100644 index 00000000000..fa284e732ba --- /dev/null +++ b/apps/studio/routes/mcp/secrets.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' + +import McpSecretsPage from '@/pages/mcp/secrets' + +export const Route = createFileRoute('/mcp/secrets')({ + component: McpSecrets, +}) + +function McpSecrets() { + return +}