stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler)

This commit is contained in:
jarek committed 2026-08-13 10:20:29 +02:00
1 parent d7f435dd5f
commit f43736d94a
28 files changed
+667 -100

No files matched your search

+112 -55
View File
@@ -23,6 +23,7 @@
{ value: 'op-connect', label: '1Password Connect' },
{ value: 'infisical', label: 'Infisical' },
{ value: 'vault', label: 'HashiCorp Vault' },
{ value: 'doppler', label: 'Doppler' },
];
// Config fields per provider type, matching the config shapes in
@@ -48,6 +49,11 @@
{ key: 'namespace', label: 'Namespace', type: 'text', required: false, placeholder: 'admin (Enterprise / HCP)' },
{ key: 'mount', label: 'KV mount', type: 'text', required: false, placeholder: 'secret' },
],
doppler: [
{ key: 'token', label: 'Token', type: 'password', required: true, placeholder: 'dp.st.... or dp.pt....', hint: 'A service token (dp.st.) already targets one config. A personal token (dp.pt.) also needs the project and config below.' },
{ key: 'project', label: 'Project', type: 'text', required: false, placeholder: 'only for a personal token (dp.pt.)' },
{ key: 'config', label: 'Config', type: 'text', required: false, placeholder: 'e.g. prd' },
],
};
export function providerTypeLabel(type: string): string {
@@ -59,9 +65,12 @@
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { FieldLabel } from '$lib/components/ui/field-label';
import { Input } from '$lib/components/ui/input';
import { Plus, Check, RefreshCw, PlugZap } from 'lucide-svelte';
import { Plus, Check, RefreshCw, PlugZap, KeyRound } from 'lucide-svelte';
import { scale } from 'svelte/transition';
import { backOut, cubicIn } from 'svelte/easing';
import { getProviderIcon } from '$lib/components/provider-icons';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
@@ -88,6 +97,9 @@
let formError = $state('');
let formSaving = $state(false);
let formTesting = $state(false);
// Brief green tick on the Test connection button right after a successful test.
let testOk = $state(false);
let testOkTimer: ReturnType<typeof setTimeout> | undefined;
const fields = $derived(PROVIDER_FIELDS[formType] ?? []);
@@ -111,14 +123,34 @@
formType = provider.type;
resetConfig();
formError = '';
// Pre-fill the NON-secret config fields (host, projectId, mount, ...) from
// the server; the token stays blank ('keep existing'). The list only has a
// summary, so fetch the single provider which returns the redacted config.
void loadProviderConfig(provider.id);
} else {
resetForm();
}
}
});
// The config never leaves the server, so on edit the fields start blank.
// Collect only the fields the user actually filled in.
async function loadProviderConfig(id: number) {
try {
const res = await fetch(`/api/secret-providers/${id}`);
if (!res.ok) return;
const data = await res.json();
const cfg = (data?.config ?? {}) as Record<string, unknown>;
const next: Record<string, string> = {};
for (const [key, value] of Object.entries(cfg)) {
if (value != null) next[key] = String(value);
}
formConfig = next; // secret fields (token) are absent -> stay blank
} catch {
// leave fields blank on failure - the user can re-enter them
}
}
// A blank secret field on edit means "keep the stored value"; non-secret fields are
// pre-filled (loadProviderConfig). Collect only the fields the user actually filled.
function collectConfig(): Record<string, string> {
const config: Record<string, string> = {};
for (const field of fields) {
@@ -128,8 +160,11 @@
return config;
}
function missingRequired(config: Record<string, string>): string | null {
function missingRequired(config: Record<string, string>, editing = false): string | null {
for (const field of fields) {
// On edit a blank secret (password) field keeps the stored value, so it is
// allowed to be empty; non-secret required fields still must be present.
if (editing && field.type === 'password') continue;
if (field.required && !config[field.key]) {
return `${field.label} is required`;
}
@@ -149,16 +184,19 @@
formError = '';
try {
const config = collectConfig();
const configProvided = Object.keys(config).length > 0;
// On edit the token is server-side (blank field = keep stored), so if the
// user hasn't typed a new one, test the STORED config; the pre-filled
// non-secret fields aren't enough to re-test from the client.
const hasNewSecret = fields.some((f) => f.type === 'password' && (config[f.key] ?? '') !== '');
let response: Response;
if (isEditing && !configProvided) {
if (isEditing && !hasNewSecret) {
// Test the stored (server-side) config.
response = await fetch(`/api/secret-providers/${provider!.id}/test`, {
method: 'POST',
});
} else {
const missing = missingRequired(config);
const missing = missingRequired(config, isEditing);
if (missing) {
formError = missing;
return;
@@ -172,6 +210,9 @@
const data = await response.json();
if (data.ok) {
toast.success('Connection works');
clearTimeout(testOkTimer);
testOk = true;
testOkTimer = setTimeout(() => (testOk = false), 2000);
} else {
toast.error(data.error || 'Connection failed');
formError = data.error || 'Connection failed';
@@ -190,17 +231,15 @@
}
const config = collectConfig();
const configProvided = Object.keys(config).length > 0;
// On create, a complete config is required. On edit, config is optional
// (blank keeps the stored config); if the user touches any field they
// must supply the whole config, since it is replaced wholesale.
if (!isEditing || configProvided) {
const missing = missingRequired(config);
if (missing) {
formError = missing;
return;
}
// On create, every required field must be present. On EDIT, a blank SECRET
// field (token) means "keep the stored value", so a required secret is allowed
// to be blank; non-secret required fields (host, projectId, ...) are pre-filled
// and still validated. The backend merges the stored secret over the blank.
const missing = missingRequired(config, isEditing);
if (missing) {
formError = missing;
return;
}
formSaving = true;
@@ -210,8 +249,11 @@
const body: Record<string, unknown> = {
name: formName.trim(),
type: formType,
// Always send config; on edit the backend keeps the stored secret when a
// secret field is blank (updateSecretProvider merges), and the non-secret
// fields are pre-filled, so `config` is the full intended coordinates.
config,
};
if (configProvided) body.config = config;
const url = isEditing
? `/api/secret-providers/${provider!.id}`
@@ -241,6 +283,8 @@
}
function handleClose() {
clearTimeout(testOkTimer);
testOk = false;
open = false;
onClose();
}
@@ -255,11 +299,12 @@
}
}}
>
<Dialog.Content class="max-w-md">
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title
>{isEditing ? "Edit" : "Add"} secret provider</Dialog.Title
>
<Dialog.Title class="flex items-center gap-2">
<KeyRound class="w-5 h-5 text-muted-foreground" />
{isEditing ? "Edit" : "Add"} secret provider
</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
{#if formError}
@@ -268,55 +313,60 @@
</div>
{/if}
<div class="space-y-2">
<Label for="provider-name">Name</Label>
<FieldLabel label="Name" forId="provider-name" required showOptional={false} />
<Input
id="provider-name"
bind:value={formName}
placeholder="Production secrets"
/>
</div>
<div class="space-y-2">
<Label for="provider-type">Provider</Label>
<div class="space-y-2 sm:w-[calc(50%-0.5rem)]">
<FieldLabel label="Provider" forId="provider-type" required showOptional={false} />
<Select.Root
type="single"
value={formType}
onValueChange={onTypeChange}
disabled={isEditing}
>
<Select.Trigger id="provider-type">
<Select.Trigger id="provider-type" class="flex w-full items-center gap-2">
{@const TriggerIcon = getProviderIcon(formType)}
<TriggerIcon class="w-4 h-4 shrink-0 text-muted-foreground" />
{providerTypeLabel(formType)}
</Select.Trigger>
<Select.Content>
{#each PROVIDER_TYPES as t (t.value)}
{@const ItemIcon = getProviderIcon(t.value)}
<Select.Item value={t.value} label={t.label}>
{t.label}
<span class="flex items-center gap-2">
<ItemIcon class="w-4 h-4 shrink-0 text-muted-foreground" />
{t.label}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#each fields as field (field.key)}
<div class="space-y-2">
<Label for={`provider-${field.key}`}>
{field.label}{#if !field.required}<span
class="text-muted-foreground"
>
(optional)</span
>{/if}
</Label>
<Input
id={`provider-${field.key}`}
type={field.type}
bind:value={formConfig[field.key]}
placeholder={isEditing && field.type === "password"
? "leave blank to keep existing"
: field.placeholder}
/>
{#if field.hint}
<p class="text-xs text-muted-foreground">{field.hint}</p>
{/if}
</div>
{/each}
<!-- Provider config fields in a 2-column grid. min-height fits the tallest
provider (5 fields = 3 rows) so the modal keeps a fixed height and does
not jump when switching providers. -->
<div class="grid grid-cols-2 gap-x-4 gap-y-3" style="min-height: 13.5rem;">
{#each fields as field (field.key)}
<div class="space-y-2">
<FieldLabel label={field.label} forId={`provider-${field.key}`} required={field.required} />
<Input
id={`provider-${field.key}`}
type={field.type}
bind:value={formConfig[field.key]}
placeholder={isEditing && field.type === "password"
? "leave blank to keep existing"
: field.placeholder}
/>
{#if field.hint}
<p class="text-xs text-muted-foreground">{field.hint}</p>
{/if}
</div>
{/each}
</div>
<p class="text-xs text-muted-foreground">
Configuration is stored encrypted.{#if isEditing}
Leave secret fields blank to keep the existing values.{/if}
@@ -327,12 +377,19 @@
variant="outline"
onclick={testCurrent}
disabled={formTesting || formSaving}
class={`transition-colors duration-300 ${testOk ? 'border-green-500/60 text-green-600 dark:text-green-400' : ''}`}
>
{#if formTesting}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<PlugZap class="w-4 h-4 mr-1" />
{/if}
<span class="inline-flex w-4 h-4 mr-1 items-center justify-center shrink-0">
{#if formTesting}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else if testOk}
<span in:scale={{ duration: 260, start: 0.4, easing: backOut }} out:scale={{ duration: 150, start: 0.6, easing: cubicIn }}>
<Check class="w-4 h-4 text-green-600 dark:text-green-400" />
</span>
{:else}
<PlugZap class="w-4 h-4" />
{/if}
</span>
Test connection
</Button>
<div class="flex-1"></div>