mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
## 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 -->
274 lines
9.6 KiB
TypeScript
274 lines
9.6 KiB
TypeScript
import { Box, Cable, Database, Server, Sparkles } from 'lucide-react'
|
|
import {
|
|
cn,
|
|
RadioGroupStacked,
|
|
RadioGroupStackedItem,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Switch,
|
|
} from 'ui'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
import {
|
|
MultiSelector,
|
|
MultiSelectorContent,
|
|
MultiSelectorItem,
|
|
MultiSelectorList,
|
|
MultiSelectorTrigger,
|
|
} from 'ui-patterns/multi-select'
|
|
|
|
import type { ConnectMode, FieldOption, ResolvedField } from './Connect.types'
|
|
import { ConnectionIcon } from './ConnectionIcon'
|
|
|
|
const MODE_ICONS: Record<string, React.ReactNode> = {
|
|
framework: <Box size={16} strokeWidth={1.5} />,
|
|
direct: <Database size={16} strokeWidth={1.5} />,
|
|
orm: <Cable size={16} strokeWidth={1.5} />,
|
|
mcp: <Sparkles size={16} strokeWidth={1.5} />,
|
|
server: <Server size={16} strokeWidth={1.5} />,
|
|
}
|
|
|
|
interface ConnectConfigSectionProps {
|
|
activeFields: ResolvedField[]
|
|
state: Record<string, string | boolean | string[]>
|
|
onFieldChange: (fieldId: string, value: string | boolean | string[]) => void
|
|
getFieldOptions: (fieldId: string) => FieldOption[]
|
|
}
|
|
|
|
export function ConnectConfigSection({
|
|
activeFields,
|
|
state,
|
|
onFieldChange,
|
|
getFieldOptions,
|
|
}: ConnectConfigSectionProps) {
|
|
if (activeFields.length === 0) return null
|
|
|
|
return (
|
|
<div className="flex flex-col gap-y-4">
|
|
{activeFields.map((field) => {
|
|
const options = getFieldOptions(field.id)
|
|
const value = state[field.id]
|
|
|
|
// Skip fields with no options (or single option that's auto-selected)
|
|
// Exception: switch and multi-select fields don't require options
|
|
if (field.type !== 'switch' && field.type !== 'multi-select') {
|
|
if (options.length === 0) return null
|
|
if (options.length === 1) return null
|
|
}
|
|
|
|
switch (field.type) {
|
|
case 'radio-grid':
|
|
return (
|
|
<FormItemLayout
|
|
key={field.id}
|
|
isReactForm={false}
|
|
layout="horizontal"
|
|
label={field.label}
|
|
>
|
|
<RadioGroupStacked
|
|
value={String(value ?? '')}
|
|
onValueChange={(v) => onFieldChange(field.id, v)}
|
|
className="flex-row gap-3 space-y-0"
|
|
>
|
|
{options.map((option) => (
|
|
<RadioGroupStackedItem
|
|
key={option.value}
|
|
id={`connect-${field.id}-${option.value}`}
|
|
value={option.value}
|
|
label=""
|
|
className="flex-1 rounded-lg text-left"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{option.icon && <ConnectionIcon supportsDarkMode icon={option.icon} />}
|
|
<span className="text-sm">{option.label}</span>
|
|
</div>
|
|
</RadioGroupStackedItem>
|
|
))}
|
|
</RadioGroupStacked>
|
|
</FormItemLayout>
|
|
)
|
|
|
|
case 'radio-list':
|
|
return (
|
|
<FormItemLayout
|
|
key={field.id}
|
|
isReactForm={false}
|
|
layout="horizontal"
|
|
label={field.label}
|
|
>
|
|
<RadioGroupStacked
|
|
value={String(value ?? '')}
|
|
onValueChange={(v) => onFieldChange(field.id, v)}
|
|
>
|
|
{options.map((option) => (
|
|
<RadioGroupStackedItem
|
|
key={option.value}
|
|
id={`connect-${field.id}-${option.value}`}
|
|
value={option.value}
|
|
label=""
|
|
className="w-full text-left"
|
|
>
|
|
<div className="flex flex-col gap-0.5">
|
|
<div className="flex items-center gap-2">
|
|
{option.icon && <ConnectionIcon icon={option.icon} />}
|
|
<span className="text-sm">{option.label}</span>
|
|
</div>
|
|
{option.description && (
|
|
<span className="text-sm text-foreground-lighter">
|
|
{option.description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</RadioGroupStackedItem>
|
|
))}
|
|
</RadioGroupStacked>
|
|
</FormItemLayout>
|
|
)
|
|
|
|
case 'select':
|
|
return (
|
|
<FormItemLayout
|
|
key={field.id}
|
|
isReactForm={false}
|
|
layout="horizontal"
|
|
label={field.label}
|
|
description={field.description}
|
|
>
|
|
<Select
|
|
value={String(value ?? '')}
|
|
onValueChange={(v) => onFieldChange(field.id, v)}
|
|
>
|
|
<SelectTrigger
|
|
size="small"
|
|
className="[&>span:first-child]:flex [&>span:first-child]:items-center [&>span:first-child]:gap-x-2"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
className="[&>span:last-child]:flex [&>span:last-child]:items-center [&>span:last-child]:gap-x-2"
|
|
>
|
|
{/*
|
|
[Joshen] Omitting MCP icons for now as the images are not optimized (large)
|
|
and is causing noticeably latency issues on the browser (even with the existing Connect UI)
|
|
*/}
|
|
{field.id === 'framework' && option.icon && (
|
|
<ConnectionIcon icon={option.icon} />
|
|
)}
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FormItemLayout>
|
|
)
|
|
|
|
case 'switch':
|
|
return (
|
|
<FormItemLayout
|
|
key={field.id}
|
|
isReactForm={false}
|
|
layout="horizontal"
|
|
label={field.label}
|
|
description={field.description}
|
|
className="[&>div>label>span]:break-keep! [&>div>label>span]:text-balance"
|
|
>
|
|
<Switch
|
|
id={field.id}
|
|
checked={Boolean(value)}
|
|
onCheckedChange={(v) => onFieldChange(field.id, v)}
|
|
/>
|
|
</FormItemLayout>
|
|
)
|
|
|
|
case 'multi-select':
|
|
return (
|
|
<FormItemLayout
|
|
key={field.id}
|
|
isReactForm={false}
|
|
layout="horizontal"
|
|
label={field.label}
|
|
description={field.description}
|
|
>
|
|
<MultiSelector
|
|
values={Array.isArray(value) ? value : []}
|
|
onValuesChange={(v) => onFieldChange(field.id, v)}
|
|
>
|
|
<MultiSelectorTrigger
|
|
className="w-full"
|
|
label="All features except Storage enabled by default"
|
|
badgeLimit="wrap"
|
|
showIcon={true}
|
|
/>
|
|
<MultiSelectorContent>
|
|
<MultiSelectorList>
|
|
{options.map((option) => (
|
|
<MultiSelectorItem
|
|
key={option.value}
|
|
value={option.value}
|
|
className="items-start"
|
|
>
|
|
<div className="flex flex-col ml-2 gap-y-0.5">
|
|
<span className="font-medium">{option.label}</span>
|
|
{option.description && (
|
|
<span className="text-xs text-foreground-light">
|
|
{option.description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</MultiSelectorItem>
|
|
))}
|
|
</MultiSelectorList>
|
|
</MultiSelectorContent>
|
|
</MultiSelector>
|
|
</FormItemLayout>
|
|
)
|
|
|
|
default:
|
|
return null
|
|
}
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface ModeSelectorProps {
|
|
modes: Array<{ id: ConnectMode; label: string; description: string }>
|
|
selected: ConnectMode
|
|
onChange: (mode: ConnectMode) => void
|
|
}
|
|
|
|
export function ModeSelector({ modes, selected, onChange }: ModeSelectorProps) {
|
|
return (
|
|
<div
|
|
className="grid rounded-lg border overflow-hidden"
|
|
style={{ gridTemplateColumns: `repeat(${modes.length}, minmax(0, 1fr))` }}
|
|
>
|
|
{modes.map((mode) => (
|
|
<button
|
|
key={mode.id}
|
|
type="button"
|
|
onClick={() => onChange(mode.id)}
|
|
className={cn(
|
|
'flex flex-col items-center gap-2 p-4 transition-colors border-r last:border-r-0',
|
|
selected === mode.id
|
|
? 'bg-surface-200'
|
|
: 'border-default hover:border-strong hover:bg-surface-100 '
|
|
)}
|
|
>
|
|
<span className="text-foreground-light">{MODE_ICONS[mode.id]}</span>
|
|
<div>
|
|
<p className="heading-default text-center">{mode.label}</p>
|
|
<p className="text-sm text-foreground-lighter text-center">{mode.description}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|