stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler)
This commit is contained in:
28 files changed
+667
-100
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user