mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): mcp elicitation url mode ui (#49954)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Initial build of the URL mode interstitial. Has complete mock data to be able to view different states (this will be stripped out in the end). A starting point for us to use as an intercept. This is linked with @barryroodt ticket in the MCP project. https://github.com/supabase/mcp/pull/412 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added an authenticated MCP Secrets page for securely storing API secrets requested by AI tools. - Added provider-aware guidance for OpenAI, Anthropic, Resend, and Stripe keys, including validation warnings and dashboard links. - Added clear success, cancellation, expiration, error, and wrong-account states with account switching. - Added loading placeholders, secret visibility controls, overwrite warnings, and accessibility announcements. - **Bug Fixes** - Improved interstitial animations to respect reduced-motion preferences. - Preserved return destinations and related parameters during sign-in flows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
67e48f51b6
commit
41e6d489f8
26 files changed
+1703
-3
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}) => (
|
||||
<InterstitialLayout
|
||||
logo={<SupabaseLogo />}
|
||||
title={title}
|
||||
description={subtitle}
|
||||
descriptionClassName="text-foreground-light"
|
||||
>
|
||||
<div className="flex flex-col gap-6 px-6 pb-6">{children}</div>
|
||||
</InterstitialLayout>
|
||||
)
|
||||
|
||||
export const InterstitialShellSkeleton = ({ children }: { children: ReactNode }) => (
|
||||
<InterstitialShell
|
||||
title={<Skeleton className="h-[18px] w-40" />}
|
||||
subtitle={
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</InterstitialShell>
|
||||
)
|
||||
|
||||
export const InterstitialFooter = ({
|
||||
children,
|
||||
align = 'center',
|
||||
}: {
|
||||
children: ReactNode
|
||||
align?: 'center' | 'start'
|
||||
}) => (
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs text-foreground-light',
|
||||
align === 'center' ? 'text-center' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
@@ -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) => (
|
||||
<InterstitialShell title={title} subtitle={subtitle}>
|
||||
<Admonition type="note" title={calloutTitle} description={calloutBody} className="mb-0" />
|
||||
<InterstitialFooter>{footer}</InterstitialFooter>
|
||||
</InterstitialShell>
|
||||
)
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, string | undefined>
|
||||
|
||||
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<SecretsParams, 'ref' | 'name'>) {
|
||||
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()}`
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<p role="status" aria-live="polite" className="sr-only">
|
||||
{getSecretsAnnouncement(areFlagsResolved ? resolvedState : undefined)}
|
||||
</p>
|
||||
|
||||
{!areFlagsResolved && <McpSecretsSkeleton />}
|
||||
|
||||
{areFlagsResolved && !isUrlModeEnabled && (
|
||||
<InterstitialTerminalScreen {...getSecretsCopy(PAUSED_STATE)} />
|
||||
)}
|
||||
|
||||
{areFlagsResolved && isUrlModeEnabled && (
|
||||
<McpSecretsCard
|
||||
state={state}
|
||||
isSaving={isSaving}
|
||||
onSave={saveSecret}
|
||||
onCancel={cancelRequest}
|
||||
onSwitchAccount={handleSwitchAccount}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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' }
|
||||
>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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.`
|
||||
}
|
||||
@@ -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<typeof McpSecretsCard>[0]['state']) =>
|
||||
customRender(
|
||||
<McpSecretsCard
|
||||
state={state}
|
||||
isSaving={false}
|
||||
onSave={noop}
|
||||
onCancel={noop}
|
||||
onSwitchAccount={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
const secretField = () => screen.getByLabelText<HTMLInputElement>('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(
|
||||
<McpSecretsCard
|
||||
state={{ status: 'form', request: { ...request, keyName: 'RESEND_API_KEY' } }}
|
||||
isSaving={false}
|
||||
onSave={noop}
|
||||
onCancel={noop}
|
||||
onSwitchAccount={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<McpSecretsCard
|
||||
state={{
|
||||
status: 'form',
|
||||
request: { ...request, ref: 'bbbbbbbbbbbbbbbbbbbb', project: 'acme-production' },
|
||||
}}
|
||||
isSaving={false}
|
||||
onSave={noop}
|
||||
onCancel={noop}
|
||||
onSwitchAccount={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<McpSecretsCard
|
||||
state={{
|
||||
status: 'form',
|
||||
request: { ...request, existingSecret: { updatedAt: new Date().toISOString() } },
|
||||
}}
|
||||
isSaving={false}
|
||||
onSave={noop}
|
||||
onCancel={noop}
|
||||
onSwitchAccount={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<McpSecretsCard
|
||||
state={{ status: 'form', request }}
|
||||
isSaving={false}
|
||||
onSave={onSave}
|
||||
onCancel={noop}
|
||||
onSwitchAccount={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<McpSecretsCard
|
||||
state={{ status: 'form', request }}
|
||||
isSaving={false}
|
||||
onSave={onSave}
|
||||
onCancel={noop}
|
||||
onSwitchAccount={noop}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.type(secretField(), ' sk-padded ')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(' sk-padded '))
|
||||
})
|
||||
})
|
||||
@@ -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 <McpSecretsSkeleton />
|
||||
|
||||
if (state.status === 'form') {
|
||||
return (
|
||||
<McpSecretsForm
|
||||
key={`${state.request.ref}:${state.request.keyName}`}
|
||||
request={state.request}
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status === 'wrong-account') {
|
||||
return (
|
||||
<McpSecretsWrongAccount signedInAs={state.signedInAs} onSwitchAccount={onSwitchAccount} />
|
||||
)
|
||||
}
|
||||
|
||||
return <InterstitialTerminalScreen {...getSecretsCopy(state)} />
|
||||
}
|
||||
@@ -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
|
||||
}) => (
|
||||
<div className="flex items-center justify-between gap-4 py-2.5 text-xs">
|
||||
<span className="shrink-0 text-foreground-light">{label}</span>
|
||||
<span className={cn('min-w-0 truncate text-right text-foreground', isMono && 'font-mono')}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const McpSecretsDetails = ({ request }: { request: SecretRequest }) => (
|
||||
<div className="divide-y rounded-md border bg-surface-75 px-4">
|
||||
<DetailRow label="Tool" value={request.tool} isMono />
|
||||
<DetailRow label="Project" value={request.project} />
|
||||
<DetailRow label="Signed in as" value={request.account} />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const McpSecretsDetailsSkeleton = () => (
|
||||
<div className="divide-y rounded-md border bg-surface-75 px-4">
|
||||
{Array.from({ length: DETAIL_ROW_COUNT }).map((_, index) => (
|
||||
<div key={index} className="flex items-center justify-between gap-4 py-2.5 text-xs">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -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<typeof FormSchema>
|
||||
|
||||
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<FormValues>({ 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 (
|
||||
<InterstitialShell
|
||||
title="Store an API key"
|
||||
subtitle="Supabase is asking for this key on behalf of a tool call. It never passes through your AI client."
|
||||
>
|
||||
<McpSecretsDetails request={request} />
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={form.handleSubmit((values) => onSave(values.secret))}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<FormItemLayout isReactForm={false} label="Key name" name={KEY_NAME_FIELD_ID}>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id={KEY_NAME_FIELD_ID}
|
||||
name={KEY_NAME_FIELD_ID}
|
||||
value={request.keyName}
|
||||
readOnly
|
||||
aria-readonly
|
||||
/>
|
||||
<InputGroupAddon align="inline-end" className="pr-2">
|
||||
<Lock size={14} className="text-foreground-muted" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItemLayout>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secret"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Secret value"
|
||||
description={getSecretHelperText(request.project)}
|
||||
>
|
||||
<InputGroup>
|
||||
<FormInputGroupInput
|
||||
{...field}
|
||||
type={isRevealed ? 'text' : 'password'}
|
||||
placeholder="Paste your key"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
disabled={isSaving}
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
data-bwignore
|
||||
/>
|
||||
<InputGroupAddon
|
||||
align="inline-end"
|
||||
className="pr-1 has-[>button]:mr-0 has-[>kbd]:mr-0"
|
||||
>
|
||||
<InputGroupButton
|
||||
size="tiny"
|
||||
variant="text"
|
||||
type="button"
|
||||
className="size-6 shrink-0 p-0"
|
||||
aria-pressed={isRevealed}
|
||||
aria-label={isRevealed ? 'Hide secret value' : 'Show secret value'}
|
||||
icon={isRevealed ? <EyeOff /> : <Eye />}
|
||||
onClick={() => setIsRevealed(!isRevealed)}
|
||||
/>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn('text-xs text-warning-600', !prefixWarning && 'sr-only')}
|
||||
>
|
||||
{prefixWarning}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Admonition
|
||||
type="default"
|
||||
description="Only continue if you asked your AI client to store this secret."
|
||||
className="mb-0"
|
||||
/>
|
||||
{overwriteWarning && (
|
||||
<Admonition type="warning" description={overwriteWarning} className="mb-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button block variant="primary" type="submit" loading={isSaving} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button block variant="text" type="button" disabled={isSaving} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{providerHint && (
|
||||
<>
|
||||
<Separator />
|
||||
<p className="text-xs text-foreground-light">
|
||||
{`Don't have your key? Create one in the `}
|
||||
{providerHint.dashboardUrl ? (
|
||||
<a
|
||||
href={providerHint.dashboardUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-foreground underline underline-offset-2"
|
||||
>
|
||||
{providerHint.name} dashboard
|
||||
</a>
|
||||
) : (
|
||||
`${providerHint.name} dashboard`
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</InterstitialShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Skeleton } from 'ui'
|
||||
|
||||
import { InterstitialShellSkeleton } from '../InterstitialShell'
|
||||
import { McpSecretsDetailsSkeleton } from './McpSecretsDetails'
|
||||
|
||||
export const McpSecretsSkeleton = () => (
|
||||
<InterstitialShellSkeleton>
|
||||
<McpSecretsDetailsSkeleton />
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-[34px] w-full" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-[34px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-[75px] w-full" />
|
||||
</InterstitialShellSkeleton>
|
||||
)
|
||||
@@ -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
|
||||
}) => (
|
||||
<InterstitialShell
|
||||
title="This account has no access"
|
||||
subtitle="It was created by a different Supabase account."
|
||||
>
|
||||
<InterstitialAccountRow displayName={signedInAs} />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button block variant="primary" onClick={onSwitchAccount}>
|
||||
Switch account
|
||||
</Button>
|
||||
<p className="text-xs text-foreground-light">
|
||||
Signing in again will probably expire this request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<InterstitialFooter align="start">
|
||||
After switching, ask your agent to run the tool again. Nothing has been stored.
|
||||
</InterstitialFooter>
|
||||
</InterstitialShell>
|
||||
)
|
||||
@@ -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<APIErrorBody>({ 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'))
|
||||
})
|
||||
})
|
||||
@@ -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<RecordedOutcome | undefined>(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<SecretsState>(() => {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<InterstitialLayoutProps>) => {
|
||||
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 = (
|
||||
<MotionCard
|
||||
layout="size"
|
||||
layout={shouldReduceMotion ? false : 'size'}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={cn('overflow-hidden w-full mx-auto', widthClassName, cardClassName)}
|
||||
>
|
||||
@@ -190,7 +191,7 @@ export const InterstitialAccountRow = ({
|
||||
>
|
||||
<ProfileImage
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
alt=""
|
||||
className="size-8 flex-shrink-0 rounded-full border border-muted"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{PAGE_TITLE}</title>
|
||||
</Head>
|
||||
<McpSecrets />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(McpSecretsPage)
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <McpSecretsPage dehydratedState={undefined} />
|
||||
}
|
||||
Reference in new issue
Block a user