refactor(settings): adopt environment-only runtime secret flow
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user