refactor(settings): adopt environment-only runtime secret flow

This commit is contained in:
2026-03-30 19:55:06 +02:00
parent fed7aa5b61
commit a19d2cbae0
19 changed files with 757 additions and 172 deletions
@@ -16,6 +16,13 @@ const CHECKBOX_ROW_CLASS =
"flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300";
type Provider = "openai" | "azure";
type RuntimeSecretSource = "environment" | "database" | "none";
type RuntimeSecretStatus = {
configured: boolean;
activeSource: RuntimeSecretSource;
hasStoredValue: boolean;
envVarNames: string[];
};
const ALL_ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const;
type SystemRole = (typeof ALL_ROLES)[number];
@@ -60,12 +67,92 @@ function parseAzureUrl(raw: string): ParsedAzureUrl | null {
}
}
function getSecretStatusTone(source: RuntimeSecretSource): string {
if (source === "environment") {
return "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300";
}
if (source === "database") {
return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300";
}
return "border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-300";
}
function getSecretStatusLabel(source: RuntimeSecretSource): string {
if (source === "environment") return "Environment";
if (source === "database") return "Legacy DB";
return "Missing";
}
function RuntimeSecretCard({
title,
description,
secret,
optionalNote,
}: {
title: string;
description: string;
secret: RuntimeSecretStatus;
optionalNote?: string;
}) {
return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</h3>
<p className="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
<span
className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] ${getSecretStatusTone(secret.activeSource)}`}
>
{getSecretStatusLabel(secret.activeSource)}
</span>
</div>
<div className="mt-3 space-y-2 text-xs text-gray-600 dark:text-gray-300">
<p>
Runtime status:{" "}
<span className="font-medium">
{secret.configured ? "configured" : "not configured"}
</span>
</p>
<p>
Provision via{" "}
{secret.envVarNames.map((name) => (
<code key={name} className="mr-1 font-mono">
{name}
</code>
))}
</p>
{optionalNote ? <p>{optionalNote}</p> : null}
{secret.activeSource === "environment" && secret.hasStoredValue ? (
<p className="text-amber-700 dark:text-amber-400">
An older database value still exists, but the environment value currently overrides it.
</p>
) : null}
{secret.activeSource === "database" ? (
<p className="text-amber-700 dark:text-amber-400">
Runtime currently still depends on a legacy database secret. Migrate it to deployment
secrets and clear the stored value afterwards.
</p>
) : null}
{secret.activeSource === "none" ? (
<p className="text-gray-500 dark:text-gray-400">
No runtime secret is available yet. The related integration will stay disabled or fail
connectivity checks until the deployment secret is set.
</p>
) : null}
</div>
</div>
);
}
export function SystemSettingsClient() {
const [provider, setProvider] = useState<Provider>("openai");
const [endpoint, setEndpoint] = useState("");
const [model, setModel] = useState("");
const [apiVersion, setApiVersion] = useState("2025-01-01-preview");
const [apiKey, setApiKey] = useState("");
const [maxTokens, setMaxTokens] = useState(2000);
const [temperature, setTemperature] = useState(1);
const [summaryPrompt, setSummaryPrompt] = useState("");
@@ -73,7 +160,6 @@ export function SystemSettingsClient() {
const [testResult, setTestResult] = useState<{
ok: boolean;
error?: string;
raw?: string | null;
} | null>(null);
const [urlPasteValue, setUrlPasteValue] = useState("");
const [urlParseError, setUrlParseError] = useState(false);
@@ -94,12 +180,10 @@ export function SystemSettingsClient() {
// DALL-E settings
const [dalleDeployment, setDalleDeployment] = useState("");
const [dalleEndpoint, setDalleEndpoint] = useState("");
const [dalleApiKey, setDalleApiKey] = useState("");
// Gemini / Image generation settings
type ImageProvider = "dalle" | "gemini";
const [imageProvider, setImageProvider] = useState<ImageProvider>("dalle");
const [geminiApiKey, setGeminiApiKey] = useState("");
const [geminiModel, setGeminiModel] = useState("");
const [imageSaved, setImageSaved] = useState(false);
@@ -107,7 +191,6 @@ export function SystemSettingsClient() {
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState(587);
const [smtpUser, setSmtpUser] = useState("");
const [smtpPassword, setSmtpPassword] = useState("");
const [smtpFrom, setSmtpFrom] = useState("");
const [smtpTls, setSmtpTls] = useState(true);
const [smtpSaved, setSmtpSaved] = useState(false);
@@ -118,7 +201,6 @@ export function SystemSettingsClient() {
// Global anonymization
const [anonymizationEnabled, setAnonymizationEnabled] = useState(false);
const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de");
const [anonymizationSeed, setAnonymizationSeed] = useState("");
const [anonymizationSaved, setAnonymizationSaved] = useState(false);
// Vacation defaults
@@ -128,7 +210,9 @@ export function SystemSettingsClient() {
// Timeline
const [undoMaxSteps, setUndoMaxSteps] = useState(50);
const [timelineSaved, setTimelineSaved] = useState(false);
const [legacyCleanupResult, setLegacyCleanupResult] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, {
staleTime: 0,
});
@@ -163,7 +247,6 @@ export function SystemSettingsClient() {
// Global anonymization
setAnonymizationEnabled(settings.anonymizationEnabled ?? false);
setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de");
setAnonymizationSeed("");
// Vacation
setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
// Timeline
@@ -171,6 +254,10 @@ export function SystemSettingsClient() {
}
}, [settings]);
function invalidateSystemSettings() {
void utils.settings.getSystemSettings.invalidate();
}
function handleUrlPaste(raw: string) {
setUrlPasteValue(raw);
if (!raw) {
@@ -196,6 +283,8 @@ export function SystemSettingsClient() {
onSuccess: () => {
setSaved(true);
setTestResult(null);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setSaved(false), 3000);
},
});
@@ -208,6 +297,7 @@ export function SystemSettingsClient() {
const saveScoreMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setScoreSaved(true);
invalidateSystemSettings();
setTimeout(() => setScoreSaved(false), 3000);
},
});
@@ -220,6 +310,8 @@ export function SystemSettingsClient() {
onSuccess: () => {
setSmtpSaved(true);
setSmtpTestResult(null);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setSmtpSaved(false), 3000);
},
});
@@ -232,6 +324,8 @@ export function SystemSettingsClient() {
const saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setAnonymizationSaved(true);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setAnonymizationSaved(false), 3000);
},
});
@@ -239,6 +333,7 @@ export function SystemSettingsClient() {
const saveVacationMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setVacationSaved(true);
invalidateSystemSettings();
setTimeout(() => setVacationSaved(false), 3000);
},
});
@@ -246,6 +341,7 @@ export function SystemSettingsClient() {
const saveTimelineMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setTimelineSaved(true);
invalidateSystemSettings();
setTimeout(() => setTimelineSaved(false), 3000);
},
});
@@ -253,10 +349,26 @@ export function SystemSettingsClient() {
const saveImageMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setImageSaved(true);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setImageSaved(false), 3000);
},
});
const clearRuntimeSecretsMutation = trpc.settings.clearStoredRuntimeSecrets.useMutation({
onSuccess: (data) => {
setLegacyCleanupResult(
data.clearedFields.length > 0
? `Cleared ${data.clearedFields.length} legacy database secret field${data.clearedFields.length === 1 ? "" : "s"}.`
: "No legacy database secrets were left to clear.",
);
invalidateSystemSettings();
},
onError: (error) => {
setLegacyCleanupResult(error.message);
},
});
const [geminiTestResult, setGeminiTestResult] = useState<{ ok: boolean; model?: string; error?: string } | null>(null);
const testGeminiMut = trpc.settings.testGeminiConnection.useMutation({
onSuccess: (data) => setGeminiTestResult(data as any),
@@ -268,7 +380,6 @@ export function SystemSettingsClient() {
smtpHost: smtpHost || undefined,
smtpPort,
smtpUser: smtpUser || undefined,
...(smtpPassword ? { smtpPassword } : {}),
smtpFrom: smtpFrom || undefined,
smtpTls,
});
@@ -288,9 +399,7 @@ export function SystemSettingsClient() {
// DALL-E fields
azureDalleDeployment: dalleDeployment || undefined,
azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined,
...(dalleApiKey ? { azureDalleApiKey: dalleApiKey } : {}),
// Gemini fields
...(geminiApiKey ? { geminiApiKey } : {}),
geminiModel: geminiModel || undefined,
});
}
@@ -299,7 +408,6 @@ export function SystemSettingsClient() {
saveAnonymizationMutation.mutate({
anonymizationEnabled,
anonymizationDomain: anonymizationDomain.trim() || "superhartmut.de",
...(anonymizationSeed.trim() ? { anonymizationSeed: anonymizationSeed.trim() } : {}),
anonymizationMode: "global",
});
}
@@ -330,10 +438,22 @@ export function SystemSettingsClient() {
aiMaxCompletionTokens: maxTokens,
aiTemperature: temperature,
aiSummaryPrompt: summaryPrompt || undefined,
...(apiKey ? { azureOpenAiApiKey: apiKey } : {}),
});
}
function handleClearLegacyRuntimeSecrets() {
if (
typeof window !== "undefined"
&& !window.confirm(
"Clear all legacy runtime secrets from database storage? Environment-based deployment secrets must already be configured.",
)
) {
return;
}
clearRuntimeSecretsMutation.mutate();
}
if (isLoading) {
return (
<div className="app-page">
@@ -342,6 +462,16 @@ export function SystemSettingsClient() {
);
}
if (!settings) {
return (
<div className="app-page">
<div className="rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300">
System settings could not be loaded.
</div>
</div>
);
}
return (
<div className="app-page space-y-6">
<div className="app-page-header gap-4">
@@ -353,6 +483,46 @@ export function SystemSettingsClient() {
</div>
</div>
{settings.legacyStoredSecretFields.length ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 dark:border-amber-800 dark:bg-amber-950/30">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-amber-900 dark:text-amber-200">
Legacy Runtime Secrets Detected
</h2>
<p className="mt-2 text-sm text-amber-900/90 dark:text-amber-100/90">
This installation still has database-stored runtime secrets. New secrets are no
longer persisted in the application. Move them to deployment-level secret
management first, then clear the legacy residue here.
</p>
<p className="mt-2 text-xs text-amber-800 dark:text-amber-300">
Affected fields:{" "}
{settings.legacyStoredSecretFields.map((field) => (
<code key={field} className="mr-1 font-mono">
{field}
</code>
))}
</p>
{legacyCleanupResult ? (
<p className="mt-2 text-xs text-amber-900 dark:text-amber-200">
{legacyCleanupResult}
</p>
) : null}
</div>
<button
type="button"
onClick={handleClearLegacyRuntimeSecrets}
disabled={clearRuntimeSecretsMutation.isPending}
className={SECONDARY_BUTTON_CLASS}
>
{clearRuntimeSecretsMutation.isPending
? "Clearing…"
: "Clear Legacy DB Secrets"}
</button>
</div>
</div>
) : null}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<div className={PANEL_CLASS}>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200 flex items-center">
@@ -495,33 +665,20 @@ export function SystemSettingsClient() {
</div>
)}
{/* API key */}
<div>
<label className={LABEL_CLASS} htmlFor="ai-key">
API Key
</label>
<input
id="ai-key"
type="password"
className={INPUT_CLASS}
placeholder={
settings?.hasApiKey
? "●●●●●●●●●●●● (already set — enter new value to replace)"
: provider === "openai"
? "sk-..."
: "Enter Azure API key"
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="new-password"
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{provider === "openai"
? "Your secret key from platform.openai.com → API keys. Starts with sk-."
: "One of the two keys from Azure Portal → your resource → Keys and Endpoint."}
{settings?.hasApiKey && " Leave blank to keep the existing key."}
</p>
</div>
<RuntimeSecretCard
title="Primary AI API Key"
description={
provider === "openai"
? "The runtime reads the OpenAI key directly from deployment secrets. Saving this form does not store or rotate secrets."
: "The runtime reads the Azure OpenAI key directly from deployment secrets. Saving this form only updates non-secret metadata."
}
secret={settings.runtimeSecrets.azureOpenAiApiKey}
optionalNote={
provider === "openai"
? "Expected source: OPENAI_API_KEY. AZURE_OPENAI_API_KEY is also accepted as a fallback."
: "Expected source: AZURE_OPENAI_API_KEY. OPENAI_API_KEY is also accepted as a fallback."
}
/>
{/* Test result */}
{testResult && (
@@ -541,16 +698,6 @@ export function SystemSettingsClient() {
<p>
<span className="font-medium">Connection failed:</span> {testResult.error}
</p>
{testResult.raw && (
<details className="text-xs">
<summary className="cursor-pointer opacity-70 hover:opacity-100">
Show raw error
</summary>
<pre className="mt-1 p-2 bg-red-100 dark:bg-red-950 rounded text-red-800 dark:text-red-200 whitespace-pre-wrap break-all font-mono">
{testResult.raw}
</pre>
</details>
)}
</div>
)}
</div>
@@ -1052,9 +1199,9 @@ export function SystemSettingsClient() {
</div>
{/* ── Image Generation ────────────────────────────────── */}
<div className={PANEL_CLASS}>
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
<div className={PANEL_CLASS}>
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
Image Generation <InfoTooltip content="Configure the image generation provider used for AI-generated project cover art." />
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
@@ -1127,30 +1274,15 @@ export function SystemSettingsClient() {
placeholder="Leave empty to use same endpoint as chat"
/>
</div>
<div>
<label className={LABEL_CLASS}>
<span className="flex items-center">
API Key{" "}
<InfoTooltip content="API key for the DALL-E endpoint. Leave empty to use the same API key as the chat model." />
<span className="ml-1 text-xs font-normal text-gray-400">(optional)</span>
</span>
</label>
<input
type="password"
className={INPUT_CLASS}
value={dalleApiKey}
onChange={(e) => setDalleApiKey(e.target.value)}
placeholder="Leave empty to use same API key as chat"
autoComplete="new-password"
/>
</div>
</>
)}
</div>
{settings?.hasDalleApiKey && (
<p className="text-xs text-green-600 dark:text-green-400">A separate DALL-E API key is stored.</p>
)}
<RuntimeSecretCard
title="Dedicated DALL-E Key"
description="Optional override for image generation. If unset, runtime falls back to the primary AI key when possible."
secret={settings.runtimeSecrets.azureDalleApiKey}
optionalNote="Use AZURE_DALLE_API_KEY only when image generation should be isolated from the primary AI credential."
/>
</div>
)}
@@ -1159,24 +1291,6 @@ export function SystemSettingsClient() {
<div className="space-y-4 rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Google Gemini Configuration</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className={LABEL_CLASS}>
<span className="flex items-center">
API Key <InfoTooltip content="Google Gemini API key from Google AI Studio (aistudio.google.com)." />
</span>
</label>
<input
type="password"
className={INPUT_CLASS}
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder={settings?.hasGeminiApiKey ? "•••••••• (key is stored)" : "Enter Gemini API key"}
autoComplete="new-password"
/>
{settings?.hasGeminiApiKey && !geminiApiKey && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">API key is stored.</p>
)}
</div>
<div>
<label className={LABEL_CLASS}>
<span className="flex items-center">
@@ -1194,6 +1308,12 @@ export function SystemSettingsClient() {
</select>
</div>
</div>
<RuntimeSecretCard
title="Gemini API Key"
description="Gemini credentials are resolved from deployment secrets only."
secret={settings.runtimeSecrets.geminiApiKey}
optionalNote="Provision GEMINI_API_KEY in the target environment before using Gemini image generation."
/>
</div>
)}
@@ -1277,24 +1397,6 @@ export function SystemSettingsClient() {
autoComplete="off"
/>
</div>
<div>
<label className={LABEL_CLASS}><span className="flex items-center">
SMTP Password <InfoTooltip content="The SMTP authentication password. Stored encrypted. Leave blank to keep the existing password." />{" "}</span>
{settings?.hasSmtpPassword && (
<span className="text-gray-400 font-normal text-xs">
(set leave blank to keep)
</span>
)}
</label>
<input
type="password"
className={INPUT_CLASS}
value={smtpPassword}
onChange={(e) => setSmtpPassword(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
<div>
<label className={LABEL_CLASS}><span className="flex items-center">From Address <InfoTooltip content="The sender email address shown in notification emails (e.g. noreply@capakraken.app)." /></span></label>
<input
@@ -1322,6 +1424,13 @@ export function SystemSettingsClient() {
</div>
</div>
<RuntimeSecretCard
title="SMTP Password"
description="SMTP credentials are provisioned outside the application and injected at runtime."
secret={settings.runtimeSecrets.smtpPassword}
optionalNote="Provision SMTP_PASSWORD in the deployment target used by the API service."
/>
<div className="flex items-center gap-3">
<button
type="button"
@@ -1476,20 +1585,20 @@ export function SystemSettingsClient() {
</div>
<div>
<label className={LABEL_CLASS}>Optional Seed Override</label>
<input
type="text"
className={INPUT_CLASS}
value={anonymizationSeed}
onChange={(e) => setAnonymizationSeed(e.target.value)}
placeholder="Leave blank to keep the current stable mapping"
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Changing the seed intentionally reshuffles aliases. Leave blank to preserve the
existing mapping.
The optional seed is now managed as a deployment secret instead of an in-app value.
Changing it intentionally reshuffles aliases.
</p>
</div>
</div>
<RuntimeSecretCard
title="Anonymization Seed"
description="The stable anonymization seed is resolved from runtime secret management."
secret={settings.runtimeSecrets.anonymizationSeed}
optionalNote="Provision ANONYMIZATION_SEED only when you need a non-default, deployment-specific alias mapping."
/>
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
High-LCR resources receive more iconic characters first. Real EIDs and emails are never
rewritten in Prisma.