Files
supabase/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx
Ali Waseem cedc4c8187 feat: Add Server lib to connect w/ ability to copy env (#47056)
## 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?

Introduced a new library called Superbase/Server. To help developers
using third party API frameworks, we want to make it easier than ever to
install and use as needed.
- One click copy
- Custom prompt to get started
- Validated API key permissions to ensure we don't leak secrets to other
users in your org/project

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## New Features
- Added a **Server** connection mode end-to-end, including mode-specific
prompting and steps for installing **`@supabase/server`** and setting
required variables.
- Added a server **.env** panel with per-variable copy, **“Copy all
variables”**, and permission-aware secret reveal/copy.

## Improvements
- Updated connection UI layouts (mode selector grid and conditional
config section).
- Improved prompt copying to use mode-specific prompt text when
available.

## Tests
- Added UI tests for server env rendering, secret reveal/copy,
**copy-all** behavior, and permission-restricted scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-18 08:41:11 -06:00

167 lines
5.5 KiB
TypeScript

import { type RefObject } from 'react'
import { Admonition } from 'ui-patterns/admonition'
import CopyButton from '@/components/ui/CopyButton'
import { BASE_PATH } from '@/lib/constants'
interface CopyPromptAdmonitionProps {
stepsContainerRef: RefObject<HTMLDivElement | null>
/** When set, the Copy prompt button uses this verbatim instead of scraping the steps. */
customPrompt?: string
}
const normalizeTextLines = (value: string) => {
return value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join('\n')
}
const getStepTextContent = (contentElement: HTMLElement) => {
const clone = contentElement.cloneNode(true) as HTMLElement
clone
.querySelectorAll('pre, button, svg, input, textarea, select, [aria-hidden="true"]')
.forEach((element) => {
element.remove()
})
clone.querySelectorAll('p, div').forEach((el) => {
el.appendChild(document.createTextNode('\n'))
})
const text = clone.textContent ?? ''
return normalizeTextLines(text)
}
const getStepCodeSnippets = (contentElement: HTMLElement) => {
const snippets: Array<{ label: string; snippet: string }> = []
const seen = new Set<string>()
const addSnippet = (label: string, snippet: string) => {
if (!snippet || seen.has(snippet)) return
seen.add(snippet)
snippets.push({ label, snippet })
}
const getSnippet = (element: Element) => {
const copyValueElement = element.closest('[data-connect-copy-value]') as HTMLElement | null
return copyValueElement?.dataset.connectCopyValue?.trim() || element.textContent?.trim()
}
const tabContents = Array.from(
contentElement.querySelectorAll('[data-connect-tab-content]')
) as HTMLElement[]
tabContents.forEach((tabContent) => {
const label = tabContent.getAttribute('data-tab-label') || 'Code'
const tabSnippets = Array.from(tabContent.querySelectorAll('pre'))
.map(getSnippet)
.filter((snippet): snippet is string => Boolean(snippet))
if (tabSnippets.length === 0) {
const inlineSnippets = Array.from(tabContent.querySelectorAll('code'))
.filter((code) => !code.closest('pre') && code.closest('.font-mono'))
.map((code) => code.textContent?.trim())
.filter((snippet): snippet is string => Boolean(snippet))
inlineSnippets.forEach((snippet, index) => {
const inlineLabel = inlineSnippets.length > 1 ? `${label} (part ${index + 1})` : label
addSnippet(inlineLabel, snippet)
})
return
}
tabSnippets.forEach((snippet, index) => {
const tabLabel = tabSnippets.length > 1 ? `${label} (part ${index + 1})` : label
addSnippet(tabLabel, snippet)
})
})
contentElement.querySelectorAll('pre').forEach((pre) => {
if (pre.closest('[data-connect-tab-content]')) return
const snippet = getSnippet(pre)
if (snippet) addSnippet('Code', snippet)
})
contentElement.querySelectorAll('code').forEach((code) => {
if (code.closest('pre')) return
if (code.closest('[data-connect-tab-content]')) return
if (!code.closest('.font-mono')) return
const snippet = code.textContent?.trim()
if (snippet) addSnippet('Code', snippet)
})
return snippets
}
export const buildConnectPrompt = (stepsContainer: HTMLElement | null) => {
const stepElements = stepsContainer?.querySelectorAll('[data-connect-step]')
if (!stepElements?.length) return ''
const promptContent = Array.from(stepElements)
.map((stepElement, index) => {
const title = stepElement.getAttribute('data-step-title') ?? `Step ${index + 1}`
const description = stepElement.getAttribute('data-step-description') ?? ''
const contentElement = stepElement.querySelector('[data-step-content]') as HTMLElement | null
const details = contentElement ? getStepTextContent(contentElement) : ''
const codeSnippets = contentElement ? getStepCodeSnippets(contentElement) : []
const sections = [
`${index + 1}. ${title}`,
description,
details ? `Details:\n${details}` : null,
codeSnippets.length
? `Code:\n${codeSnippets
.map(({ label, snippet }) => `File: ${label}\n\`\`\`\n${snippet}\n\`\`\``)
.join('\n\n')}`
: null,
].filter(Boolean)
return sections.join('\n')
})
.join('\n\n')
return promptContent
}
export function CopyPromptAdmonition({
stepsContainerRef,
customPrompt,
}: CopyPromptAdmonitionProps) {
const handleCopyPrompt = () => {
return customPrompt ?? buildConnectPrompt(stepsContainerRef.current)
}
return (
<Admonition
type="tip"
showIcon={false}
layout="horizontal"
actions={
<CopyButton variant="default" copyLabel="Copy prompt" asyncText={handleCopyPrompt} />
}
>
<div className="absolute -inset-16 z-0 opacity-50">
<img
src={`${BASE_PATH}/img/reports/bg-grafana-dark.svg`}
alt="Supabase Grafana"
className="w-full h-full object-cover object-right hidden dark:block"
/>
<img
src={`${BASE_PATH}/img/reports/bg-grafana-light.svg`}
alt="Supabase Grafana"
className="w-full h-full object-cover object-right dark:hidden"
/>
<div className="absolute inset-0 bg-linear-to-r from-background-alternative to-transparent" />
</div>
<div className="relative flex flex-col md:flex-row md:items-center gap-y-2 md:gap-x-8 justify-between">
<div className="flex flex-col gap-y-0.5">
<p className="heading-default">Give your agent everything it needs</p>
</div>
</div>
</Admonition>
)
}