refactor(admin): split system settings into section modules
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,405 @@
|
|||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import {
|
||||||
|
INPUT_CLASS,
|
||||||
|
LABEL_CLASS,
|
||||||
|
PANEL_CLASS,
|
||||||
|
PRIMARY_BUTTON_CLASS,
|
||||||
|
SECONDARY_BUTTON_CLASS,
|
||||||
|
RuntimeSecretCard,
|
||||||
|
type Provider,
|
||||||
|
type RuntimeSecretStatus,
|
||||||
|
type SaveResult,
|
||||||
|
type UrlParsedType,
|
||||||
|
} from "./shared.js";
|
||||||
|
|
||||||
|
type AiProviderPanelProps = {
|
||||||
|
provider: Provider;
|
||||||
|
endpoint: string;
|
||||||
|
model: string;
|
||||||
|
apiVersion: string;
|
||||||
|
urlPasteValue: string;
|
||||||
|
urlParseError: boolean;
|
||||||
|
urlParsedType: UrlParsedType;
|
||||||
|
runtimeSecret: RuntimeSecretStatus;
|
||||||
|
testResult: SaveResult | null;
|
||||||
|
isSaving: boolean;
|
||||||
|
isTesting: boolean;
|
||||||
|
saved: boolean;
|
||||||
|
onProviderChange: (provider: Provider) => void;
|
||||||
|
onEndpointChange: (value: string) => void;
|
||||||
|
onModelChange: (value: string) => void;
|
||||||
|
onApiVersionChange: (value: string) => void;
|
||||||
|
onUrlPaste: (value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onTest: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProviderPanel({
|
||||||
|
provider,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
apiVersion,
|
||||||
|
urlPasteValue,
|
||||||
|
urlParseError,
|
||||||
|
urlParsedType,
|
||||||
|
runtimeSecret,
|
||||||
|
testResult,
|
||||||
|
isSaving,
|
||||||
|
isTesting,
|
||||||
|
saved,
|
||||||
|
onProviderChange,
|
||||||
|
onEndpointChange,
|
||||||
|
onModelChange,
|
||||||
|
onApiVersionChange,
|
||||||
|
onUrlPaste,
|
||||||
|
onSave,
|
||||||
|
onTest,
|
||||||
|
}: AiProviderPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className={PANEL_CLASS}>
|
||||||
|
<h2 className="flex items-center text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
|
||||||
|
AI Provider{" "}
|
||||||
|
<InfoTooltip content="Configure the AI service used for generating resource skill profile summaries. Either OpenAI directly or Azure OpenAI Service." />
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>Provider</label>
|
||||||
|
<div className="flex w-fit overflow-hidden rounded-xl border border-gray-200 dark:border-gray-600">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onProviderChange("openai")}
|
||||||
|
className={`px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
provider === "openai"
|
||||||
|
? "bg-brand-600 text-white"
|
||||||
|
: "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
OpenAI
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onProviderChange("azure")}
|
||||||
|
className={`border-l border-gray-200 px-4 py-2 text-sm font-semibold transition-colors dark:border-gray-600 ${
|
||||||
|
provider === "azure"
|
||||||
|
? "bg-brand-600 text-white"
|
||||||
|
: "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Azure OpenAI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{provider === "openai"
|
||||||
|
? "Use a standard OpenAI API key from platform.openai.com."
|
||||||
|
: "Use a deployment on your own Azure OpenAI resource."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{provider === "azure" ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-700 dark:bg-blue-900/20">
|
||||||
|
<p className="text-xs font-medium text-blue-800 dark:text-blue-300">
|
||||||
|
Paste a full completion URL to auto-fill all fields below:
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className={`${INPUT_CLASS} border-blue-300 dark:border-blue-600`}
|
||||||
|
placeholder="https://…cognitiveservices.azure.com/openai/deployments/gpt-5/chat/completions?api-version=2025-01-01-preview"
|
||||||
|
value={urlPasteValue}
|
||||||
|
onChange={(event) => onUrlPaste(event.target.value)}
|
||||||
|
/>
|
||||||
|
{urlParseError ? (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400">
|
||||||
|
Could not parse URL; expected either a Chat Completions URL (
|
||||||
|
<code className="font-mono">/openai/deployments/…/chat/completions</code>) or a
|
||||||
|
Responses API URL (<code className="font-mono">/openai/responses</code>).
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{urlParsedType === "responses" ? (
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
Responses API URL detected. Endpoint and api-version were filled in, but the
|
||||||
|
deployment or model name still has to be entered manually below.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{urlParsedType === "completions" ? (
|
||||||
|
<p className="text-xs text-green-700 dark:text-green-400">All fields filled from URL.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS} htmlFor="ai-endpoint">
|
||||||
|
Endpoint (base URL)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ai-endpoint"
|
||||||
|
type="url"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
placeholder="https://myinstance.cognitiveservices.azure.com"
|
||||||
|
value={endpoint}
|
||||||
|
onChange={(event) => onEndpointChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Everything up to (not including) <code className="font-mono">/openai/…</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS} htmlFor="ai-model">
|
||||||
|
{provider === "azure" ? "Deployment Name" : "Model Name"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ai-model"
|
||||||
|
type="text"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
placeholder={provider === "azure" ? "my-gpt4o-deployment" : "gpt-4o-mini"}
|
||||||
|
value={model}
|
||||||
|
onChange={(event) => onModelChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{provider === "azure"
|
||||||
|
? "The deployment name chosen when deploying the model in Azure."
|
||||||
|
: "The model identifier, for example gpt-4o-mini or gpt-4o."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{provider === "azure" ? (
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS} htmlFor="ai-api-version">
|
||||||
|
API Version
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ai-api-version"
|
||||||
|
type="text"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
placeholder="2025-01-01-preview"
|
||||||
|
value={apiVersion}
|
||||||
|
onChange={(event) => onApiVersionChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
The <code className="font-mono">api-version</code> query parameter from the endpoint
|
||||||
|
URL. Default: <code className="font-mono">2025-01-01-preview</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<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={runtimeSecret}
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{testResult ? (
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl border px-4 py-3 text-sm ${
|
||||||
|
testResult.ok
|
||||||
|
? "border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/30 dark:text-green-300"
|
||||||
|
: "border-red-200 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-900/30 dark:text-red-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.ok ? (
|
||||||
|
<span className="font-medium">
|
||||||
|
Connection successful. AI summaries are ready to use.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Connection failed:</span> {testResult.error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={PRIMARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save Settings"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={isTesting}
|
||||||
|
className={SECONDARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{isTesting ? "Testing…" : "Test Connection"}
|
||||||
|
</button>
|
||||||
|
{saved ? (
|
||||||
|
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerationSettingsPanelProps = {
|
||||||
|
maxTokens: number;
|
||||||
|
temperature: number;
|
||||||
|
summaryPrompt: string;
|
||||||
|
defaultSummaryPrompt: string;
|
||||||
|
isSaving: boolean;
|
||||||
|
saved: boolean;
|
||||||
|
onMaxTokensChange: (value: number) => void;
|
||||||
|
onTemperatureChange: (value: number) => void;
|
||||||
|
onSummaryPromptChange: (value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onResetSummaryPrompt: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GenerationSettingsPanel({
|
||||||
|
maxTokens,
|
||||||
|
temperature,
|
||||||
|
summaryPrompt,
|
||||||
|
defaultSummaryPrompt,
|
||||||
|
isSaving,
|
||||||
|
saved,
|
||||||
|
onMaxTokensChange,
|
||||||
|
onTemperatureChange,
|
||||||
|
onSummaryPromptChange,
|
||||||
|
onSave,
|
||||||
|
onResetSummaryPrompt,
|
||||||
|
}: GenerationSettingsPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className={PANEL_CLASS}>
|
||||||
|
<h2 className="flex items-center text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
|
||||||
|
Generation Settings{" "}
|
||||||
|
<InfoTooltip content="Fine-tune how the AI generates skill profile summaries. These settings affect output length, creativity, and the prompt template." />
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<label className={LABEL_CLASS} htmlFor="ai-max-tokens">
|
||||||
|
Max Completion Tokens
|
||||||
|
</label>
|
||||||
|
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">{maxTokens}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="ai-max-tokens"
|
||||||
|
type="range"
|
||||||
|
min={50}
|
||||||
|
max={2000}
|
||||||
|
step={50}
|
||||||
|
value={maxTokens}
|
||||||
|
onChange={(event) => onMaxTokensChange(Number(event.target.value))}
|
||||||
|
className="w-full accent-brand-600"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-between text-xs text-gray-400">
|
||||||
|
<span>500</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{maxTokens < 1000
|
||||||
|
? "⚠ May be empty for reasoning models (GPT-5, o1, o3)"
|
||||||
|
: maxTokens <= 2000
|
||||||
|
? "Recommended for reasoning models ✓"
|
||||||
|
: maxTokens <= 4000
|
||||||
|
? "High; allows longer bios"
|
||||||
|
: "Very high"}
|
||||||
|
</span>
|
||||||
|
<span>16000</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Reasoning models consume tokens internally before writing output. Keep this at 2000 or
|
||||||
|
above to avoid empty responses.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<label className={LABEL_CLASS} htmlFor="ai-temperature">
|
||||||
|
Temperature
|
||||||
|
</label>
|
||||||
|
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">
|
||||||
|
{temperature.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="ai-temperature"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
value={temperature}
|
||||||
|
onChange={(event) => onTemperatureChange(Number(event.target.value))}
|
||||||
|
className="w-full accent-brand-600"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-between text-xs text-gray-400">
|
||||||
|
<span>0 — deterministic</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{temperature <= 0.3
|
||||||
|
? "Factual & consistent"
|
||||||
|
: temperature <= 0.9
|
||||||
|
? "Balanced"
|
||||||
|
: temperature <= 1.0
|
||||||
|
? "Default (1) — recommended ✓"
|
||||||
|
: temperature <= 1.2
|
||||||
|
? "Creative"
|
||||||
|
: "Very creative / unpredictable"}
|
||||||
|
</span>
|
||||||
|
<span>2 — creative</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Some models only accept the default value of 1. If generation fails with a temperature
|
||||||
|
error, the system retries automatically without it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<label className={LABEL_CLASS} htmlFor="ai-prompt">
|
||||||
|
Profile Summary Prompt
|
||||||
|
</label>
|
||||||
|
{summaryPrompt ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onResetSummaryPrompt}
|
||||||
|
className="text-xs text-brand-600 hover:underline"
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="ai-prompt"
|
||||||
|
rows={10}
|
||||||
|
value={summaryPrompt || defaultSummaryPrompt}
|
||||||
|
onChange={(event) => onSummaryPromptChange(event.target.value)}
|
||||||
|
className={`${INPUT_CLASS} resize-y font-mono text-xs leading-relaxed`}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Available placeholders: <code className="font-mono">{"{role}"}</code>{" "}
|
||||||
|
<code className="font-mono">{"{chapter}"}</code>{" "}
|
||||||
|
<code className="font-mono">{"{mainSkills}"}</code>{" "}
|
||||||
|
<code className="font-mono">{"{topSkills}"}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={PRIMARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save Settings"}
|
||||||
|
</button>
|
||||||
|
{saved ? (
|
||||||
|
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import {
|
||||||
|
INPUT_CLASS,
|
||||||
|
LABEL_CLASS,
|
||||||
|
PANEL_CLASS,
|
||||||
|
PRIMARY_BUTTON_CLASS,
|
||||||
|
RuntimeSecretCard,
|
||||||
|
type RuntimeSecrets,
|
||||||
|
} from "./shared.js";
|
||||||
|
|
||||||
|
type AnonymizationSettingsPanelProps = {
|
||||||
|
anonymizationEnabled: boolean;
|
||||||
|
anonymizationDomain: string;
|
||||||
|
anonymizationSaved: boolean;
|
||||||
|
anonymizationSecret: RuntimeSecrets["anonymizationSeed"];
|
||||||
|
isSaving: boolean;
|
||||||
|
onAnonymizationEnabledChange: (value: boolean) => void;
|
||||||
|
onAnonymizationDomainChange: (value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AnonymizationSettingsPanel({
|
||||||
|
anonymizationEnabled,
|
||||||
|
anonymizationDomain,
|
||||||
|
anonymizationSaved,
|
||||||
|
anonymizationSecret,
|
||||||
|
isSaving,
|
||||||
|
onAnonymizationEnabledChange,
|
||||||
|
onAnonymizationDomainChange,
|
||||||
|
onSave,
|
||||||
|
}: AnonymizationSettingsPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className={PANEL_CLASS}>
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Viewer Anonymization{" "}
|
||||||
|
<InfoTooltip content="Replace displayed identities with deterministic aliases for demos and screenshots." />
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Global debug mode that keeps real identities in the database but replaces displayed
|
||||||
|
resource names, EIDs, and emails with stable aliases across sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={anonymizationEnabled}
|
||||||
|
onChange={(event) => onAnonymizationEnabledChange(event.target.checked)}
|
||||||
|
className="mt-0.5 rounded border-gray-300 text-brand-600"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Enable global stable anonymization
|
||||||
|
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
API responses switch to deterministic aliases like <code>Iron Man</code>,{" "}
|
||||||
|
<code>iron.man</code>, and <code>iron.man@superhartmut.de</code>.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>Alias Email Domain</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={anonymizationDomain}
|
||||||
|
onChange={(event) => onAnonymizationDomainChange(event.target.value)}
|
||||||
|
placeholder="superhartmut.de"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Used for generated alias emails only. Stored resource emails stay unchanged.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>Optional Seed Override</label>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
The optional seed is managed as a deployment secret instead of an in-app value.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RuntimeSecretCard
|
||||||
|
title="Anonymization Seed"
|
||||||
|
description="The stable anonymization seed is resolved from runtime secret management."
|
||||||
|
secret={anonymizationSecret}
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={PRIMARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save Anonymization Settings"}
|
||||||
|
</button>
|
||||||
|
{anonymizationSaved ? (
|
||||||
|
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import {
|
||||||
|
INPUT_CLASS,
|
||||||
|
LABEL_CLASS,
|
||||||
|
PANEL_CLASS,
|
||||||
|
PRIMARY_BUTTON_CLASS,
|
||||||
|
RuntimeSecretCard,
|
||||||
|
type GeminiTestResult,
|
||||||
|
type ImageProvider,
|
||||||
|
type Provider,
|
||||||
|
type RuntimeSecrets,
|
||||||
|
} from "./shared.js";
|
||||||
|
|
||||||
|
type ImageGenerationPanelProps = {
|
||||||
|
provider: Provider;
|
||||||
|
imageProvider: ImageProvider;
|
||||||
|
dalleDeployment: string;
|
||||||
|
dalleEndpoint: string;
|
||||||
|
geminiModel: string;
|
||||||
|
imageSaved: boolean;
|
||||||
|
geminiTestResult: GeminiTestResult | null;
|
||||||
|
runtimeSecrets: RuntimeSecrets;
|
||||||
|
isSaving: boolean;
|
||||||
|
isTestingGemini: boolean;
|
||||||
|
onImageProviderChange: (provider: ImageProvider) => void;
|
||||||
|
onDalleDeploymentChange: (value: string) => void;
|
||||||
|
onDalleEndpointChange: (value: string) => void;
|
||||||
|
onGeminiModelChange: (value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onTestGemini: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImageGenerationPanel({
|
||||||
|
provider,
|
||||||
|
imageProvider,
|
||||||
|
dalleDeployment,
|
||||||
|
dalleEndpoint,
|
||||||
|
geminiModel,
|
||||||
|
imageSaved,
|
||||||
|
geminiTestResult,
|
||||||
|
runtimeSecrets,
|
||||||
|
isSaving,
|
||||||
|
isTestingGemini,
|
||||||
|
onImageProviderChange,
|
||||||
|
onDalleDeploymentChange,
|
||||||
|
onDalleEndpointChange,
|
||||||
|
onGeminiModelChange,
|
||||||
|
onSave,
|
||||||
|
onTestGemini,
|
||||||
|
}: ImageGenerationPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className={PANEL_CLASS}>
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Image Generation{" "}
|
||||||
|
<InfoTooltip content="Configure the image generation provider used for AI-generated project cover art." />
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Used to generate AI cover art for projects. Configure at least one provider below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>Provider</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="imageProvider"
|
||||||
|
value="dalle"
|
||||||
|
checked={imageProvider === "dalle"}
|
||||||
|
onChange={() => onImageProviderChange("dalle")}
|
||||||
|
className="accent-brand-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
DALL-E (Azure OpenAI / OpenAI)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="imageProvider"
|
||||||
|
value="gemini"
|
||||||
|
checked={imageProvider === "gemini"}
|
||||||
|
onChange={() => onImageProviderChange("gemini")}
|
||||||
|
className="accent-brand-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Google Gemini
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageProvider === "dalle" ? (
|
||||||
|
<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">
|
||||||
|
DALL-E Configuration
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>
|
||||||
|
<span className="flex items-center">
|
||||||
|
Deployment Name{" "}
|
||||||
|
<InfoTooltip content="For OpenAI this is the model name, for Azure it is the deployment name." />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={dalleDeployment}
|
||||||
|
onChange={(event) => onDalleDeploymentChange(event.target.value)}
|
||||||
|
placeholder="dall-e-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{provider === "azure" ? (
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>
|
||||||
|
<span className="flex items-center">
|
||||||
|
Endpoint{" "}
|
||||||
|
<InfoTooltip content="Leave empty to use the same endpoint as the chat model." />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={dalleEndpoint}
|
||||||
|
onChange={(event) => onDalleEndpointChange(event.target.value)}
|
||||||
|
placeholder="Leave empty to use same endpoint as chat"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<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={runtimeSecrets.azureDalleApiKey}
|
||||||
|
optionalNote="Use AZURE_DALLE_API_KEY only when image generation should be isolated from the primary AI credential."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{imageProvider === "gemini" ? (
|
||||||
|
<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">
|
||||||
|
Model{" "}
|
||||||
|
<InfoTooltip content="Gemini model for image generation. The default model supports image output." />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={geminiModel || "gemini-2.5-flash-image"}
|
||||||
|
onChange={(event) => onGeminiModelChange(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="gemini-2.5-flash-image">Gemini 2.5 Flash Image — fast, high-volume</option>
|
||||||
|
<option value="gemini-3-pro-image-preview">Gemini 3 Pro Image Preview — high-fidelity</option>
|
||||||
|
<option value="gemini-3.1-flash-image-preview">Gemini 3.1 Flash Image Preview — latest</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RuntimeSecretCard
|
||||||
|
title="Gemini API Key"
|
||||||
|
description="Gemini credentials are resolved from deployment secrets only."
|
||||||
|
secret={runtimeSecrets.geminiApiKey}
|
||||||
|
optionalNote="Provision GEMINI_API_KEY in the target environment before using Gemini image generation."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={PRIMARY_BUTTON_CLASS}
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Save Image Settings"}
|
||||||
|
</button>
|
||||||
|
{imageProvider === "gemini" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
disabled={isTestingGemini}
|
||||||
|
onClick={onTestGemini}
|
||||||
|
>
|
||||||
|
{isTestingGemini ? "Testing..." : "Test Gemini"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{imageSaved ? (
|
||||||
|
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{geminiTestResult ? (
|
||||||
|
<div
|
||||||
|
className={`mt-3 rounded-lg border px-3 py-2 text-sm ${
|
||||||
|
geminiTestResult.ok
|
||||||
|
? "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300"
|
||||||
|
: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{geminiTestResult.ok
|
||||||
|
? `Gemini image generation works. Model: ${geminiTestResult.model}`
|
||||||
|
: `Test failed: ${geminiTestResult.error}`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { SECONDARY_BUTTON_CLASS } from "./shared.js";
|
||||||
|
|
||||||
|
type LegacyRuntimeSecretsNoticeProps = {
|
||||||
|
fields: string[];
|
||||||
|
result: string | null;
|
||||||
|
isPending: boolean;
|
||||||
|
onClear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LegacyRuntimeSecretsNotice({
|
||||||
|
fields,
|
||||||
|
result,
|
||||||
|
isPending,
|
||||||
|
onClear,
|
||||||
|
}: LegacyRuntimeSecretsNoticeProps) {
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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:{" "}
|
||||||
|
{fields.map((field) => (
|
||||||
|
<code key={field} className="mr-1 font-mono">
|
||||||
|
{field}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
{result ? (
|
||||||
|
<p className="mt-2 text-xs text-amber-900 dark:text-amber-200">{result}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
disabled={isPending}
|
||||||
|
className={SECONDARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{isPending ? "Clearing…" : "Clear Legacy DB Secrets"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import {
|
||||||
|
CHECKBOX_ROW_CLASS,
|
||||||
|
INPUT_CLASS,
|
||||||
|
LABEL_CLASS,
|
||||||
|
PANEL_CLASS,
|
||||||
|
PRIMARY_BUTTON_CLASS,
|
||||||
|
SECONDARY_BUTTON_CLASS,
|
||||||
|
RuntimeSecretCard,
|
||||||
|
type RuntimeSecrets,
|
||||||
|
type SaveResult,
|
||||||
|
} from "./shared.js";
|
||||||
|
|
||||||
|
type SmtpSettingsPanelProps = {
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
smtpUser: string;
|
||||||
|
smtpFrom: string;
|
||||||
|
smtpTls: boolean;
|
||||||
|
smtpSaved: boolean;
|
||||||
|
smtpTestResult: SaveResult | null;
|
||||||
|
smtpSecret: RuntimeSecrets["smtpPassword"];
|
||||||
|
isSaving: boolean;
|
||||||
|
isTesting: boolean;
|
||||||
|
onSmtpHostChange: (value: string) => void;
|
||||||
|
onSmtpPortChange: (value: number) => void;
|
||||||
|
onSmtpUserChange: (value: string) => void;
|
||||||
|
onSmtpFromChange: (value: string) => void;
|
||||||
|
onSmtpTlsChange: (value: boolean) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onTest: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SmtpSettingsPanel({
|
||||||
|
smtpHost,
|
||||||
|
smtpPort,
|
||||||
|
smtpUser,
|
||||||
|
smtpFrom,
|
||||||
|
smtpTls,
|
||||||
|
smtpSaved,
|
||||||
|
smtpTestResult,
|
||||||
|
smtpSecret,
|
||||||
|
isSaving,
|
||||||
|
isTesting,
|
||||||
|
onSmtpHostChange,
|
||||||
|
onSmtpPortChange,
|
||||||
|
onSmtpUserChange,
|
||||||
|
onSmtpFromChange,
|
||||||
|
onSmtpTlsChange,
|
||||||
|
onSave,
|
||||||
|
onTest,
|
||||||
|
}: SmtpSettingsPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className={PANEL_CLASS}>
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Email Notifications (SMTP){" "}
|
||||||
|
<InfoTooltip content="Configure SMTP to send email notifications for vacation approvals and rejections." />
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Used to send email notifications when vacation requests are approved or rejected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>
|
||||||
|
<span className="flex items-center">
|
||||||
|
SMTP Host{" "}
|
||||||
|
<InfoTooltip content="The SMTP server hostname, for example smtp.gmail.com or smtp.office365.com." />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={smtpHost}
|
||||||
|
onChange={(event) => onSmtpHostChange(event.target.value)}
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>
|
||||||
|
<span className="flex items-center">
|
||||||
|
SMTP Port{" "}
|
||||||
|
<InfoTooltip content="Common ports: 587 for STARTTLS, 465 for SSL/TLS, 25 for unencrypted." />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={smtpPort}
|
||||||
|
onChange={(event) => onSmtpPortChange(parseInt(event.target.value, 10))}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>
|
||||||
|
<span className="flex items-center">
|
||||||
|
SMTP Username{" "}
|
||||||
|
<InfoTooltip content="Authentication username for the SMTP server." />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={smtpUser}
|
||||||
|
onChange={(event) => onSmtpUserChange(event.target.value)}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>
|
||||||
|
<span className="flex items-center">
|
||||||
|
From Address{" "}
|
||||||
|
<InfoTooltip content="The sender email address shown in notification emails." />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={smtpFrom}
|
||||||
|
onChange={(event) => onSmtpFromChange(event.target.value)}
|
||||||
|
placeholder="noreply@capakraken.app"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="smtpTls"
|
||||||
|
checked={smtpTls}
|
||||||
|
onChange={(event) => onSmtpTlsChange(event.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-brand-600"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="smtpTls"
|
||||||
|
className="cursor-pointer text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Use TLS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RuntimeSecretCard
|
||||||
|
title="SMTP Password"
|
||||||
|
description="SMTP credentials are provisioned outside the application and injected at runtime."
|
||||||
|
secret={smtpSecret}
|
||||||
|
optionalNote="Provision SMTP_PASSWORD in the deployment target used by the API service."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={PRIMARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save SMTP Settings"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={isTesting}
|
||||||
|
className={SECONDARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{isTesting ? "Testing…" : "Test Connection"}
|
||||||
|
</button>
|
||||||
|
{smtpSaved ? (
|
||||||
|
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||||
|
) : null}
|
||||||
|
{smtpTestResult ? (
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
smtpTestResult.ok
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-red-500 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{smtpTestResult.ok ? "✓ Connection successful" : `✗ ${smtpTestResult.error}`}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import { INPUT_CLASS, LABEL_CLASS, PANEL_CLASS, PRIMARY_BUTTON_CLASS } from "./shared.js";
|
||||||
|
|
||||||
|
type TimelineSettingsPanelProps = {
|
||||||
|
undoMaxSteps: number;
|
||||||
|
timelineSaved: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
onUndoMaxStepsChange: (value: number) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimelineSettingsPanel({
|
||||||
|
undoMaxSteps,
|
||||||
|
timelineSaved,
|
||||||
|
isSaving,
|
||||||
|
onUndoMaxStepsChange,
|
||||||
|
onSave,
|
||||||
|
}: TimelineSettingsPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className={PANEL_CLASS}>
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Timeline{" "}
|
||||||
|
<InfoTooltip content="Settings for the timeline view, including undo history depth." />
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Configure timeline behavior and undo or redo history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<label className={LABEL_CLASS}>Undo History Depth</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={undoMaxSteps}
|
||||||
|
onChange={(event) => onUndoMaxStepsChange(parseInt(event.target.value, 10) || 50)}
|
||||||
|
min={1}
|
||||||
|
max={200}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Maximum number of undo steps for timeline operations. Default: 50.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={PRIMARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save Timeline Settings"}
|
||||||
|
</button>
|
||||||
|
{timelineSaved ? (
|
||||||
|
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import { INPUT_CLASS, LABEL_CLASS, PANEL_CLASS, PRIMARY_BUTTON_CLASS } from "./shared.js";
|
||||||
|
|
||||||
|
type VacationSettingsPanelProps = {
|
||||||
|
vacationDefaultDays: number;
|
||||||
|
vacationSaved: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
onVacationDefaultDaysChange: (value: number) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VacationSettingsPanel({
|
||||||
|
vacationDefaultDays,
|
||||||
|
vacationSaved,
|
||||||
|
isSaving,
|
||||||
|
onVacationDefaultDaysChange,
|
||||||
|
onSave,
|
||||||
|
}: VacationSettingsPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className={PANEL_CLASS}>
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Vacation Defaults{" "}
|
||||||
|
<InfoTooltip content="Sets the default vacation entitlement applied when creating new resources." />
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Default annual leave entitlement for new resources and the entitlement bulk-set tool.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<label className={LABEL_CLASS}>
|
||||||
|
<span className="flex items-center">
|
||||||
|
Default Annual Leave Days{" "}
|
||||||
|
<InfoTooltip content="The number of vacation days granted per year." />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={vacationDefaultDays}
|
||||||
|
onChange={(event) => onVacationDefaultDaysChange(parseInt(event.target.value, 10))}
|
||||||
|
min={0}
|
||||||
|
max={365}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Applied when creating new entitlement records for resources.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={PRIMARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save Vacation Settings"}
|
||||||
|
</button>
|
||||||
|
{vacationSaved ? (
|
||||||
|
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { CHECKBOX_ROW_CLASS, LABEL_CLASS, PANEL_STRONG_CLASS, PRIMARY_BUTTON_CLASS, SECONDARY_BUTTON_CLASS, ALL_ROLES, type ScoreWeights, type SystemRole } from "./shared.js";
|
||||||
|
|
||||||
|
type WeightKey = keyof ScoreWeights;
|
||||||
|
|
||||||
|
type WeightDefinition = {
|
||||||
|
key: WeightKey;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
details: string;
|
||||||
|
formula: string;
|
||||||
|
example: string;
|
||||||
|
range: string;
|
||||||
|
warning?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEIGHT_DEFINITIONS: WeightDefinition[] = [
|
||||||
|
{
|
||||||
|
key: "skillDepth",
|
||||||
|
code: "D",
|
||||||
|
title: "Skill Depth",
|
||||||
|
details: "Average proficiency across all skills, scaled to 0–100. Expert-heavy profiles score near 100.",
|
||||||
|
formula: "D = round((avg_proficiency / 5) × 100)",
|
||||||
|
example: "avg 4.0/5 → 80",
|
||||||
|
range: "0 = all Beginner · 100 = all Expert",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "skillBreadth",
|
||||||
|
code: "B",
|
||||||
|
title: "Skill Breadth",
|
||||||
|
details: "Number of distinct skills listed. Ten points per skill, capped at 100 for ten or more skills.",
|
||||||
|
formula: "B = min(100, skill_count × 10)",
|
||||||
|
example: "7 skills → 70",
|
||||||
|
range: "0 = no skills · 100 = 10+ skills",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "costEfficiency",
|
||||||
|
code: "C",
|
||||||
|
title: "Cost Efficiency",
|
||||||
|
details: "Inverse LCR versus the organizational maximum. Cheapest resource scores 100, most expensive scores 0.",
|
||||||
|
formula: "C = round((1 − LCR / max_LCR) × 100)",
|
||||||
|
example: "60€ vs 120€ → 50",
|
||||||
|
range: "0 = highest LCR · 100 = lowest LCR",
|
||||||
|
warning: "If all resources share the same LCR, everyone scores 0 on this dimension.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chargeability",
|
||||||
|
code: "A",
|
||||||
|
title: "Chargeability",
|
||||||
|
details: "Distance from the personal chargeability target over the last 90 days. On target scores 100.",
|
||||||
|
formula: "A = max(0, 100 − |target% − actual%| × 2)",
|
||||||
|
example: "10 pp off → 80",
|
||||||
|
range: "0 = 50+ pp off target · 100 = exactly on target",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "experience",
|
||||||
|
code: "E",
|
||||||
|
title: "Experience",
|
||||||
|
details: "Average years of experience across skills with explicit years data, capped at 10 years.",
|
||||||
|
formula: "E = min(100, avg_years × 10)",
|
||||||
|
example: "6.5 yrs → 65 · 10+ yrs → 100",
|
||||||
|
range: "0 = no years data · 100 = 10+ years average",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type ValueScorePanelProps = {
|
||||||
|
scoreWeights: ScoreWeights;
|
||||||
|
scoreVisibleRoles: SystemRole[];
|
||||||
|
weightSum: number;
|
||||||
|
weightSumOk: boolean;
|
||||||
|
scoreSaved: boolean;
|
||||||
|
recomputeResult: { updated: number } | null;
|
||||||
|
isSaving: boolean;
|
||||||
|
isRecomputing: boolean;
|
||||||
|
onUpdateWeight: (key: keyof ScoreWeights, value: number) => void;
|
||||||
|
onToggleRole: (role: SystemRole) => void;
|
||||||
|
onRecompute: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ValueScorePanel({
|
||||||
|
scoreWeights,
|
||||||
|
scoreVisibleRoles,
|
||||||
|
weightSum,
|
||||||
|
weightSumOk,
|
||||||
|
scoreSaved,
|
||||||
|
recomputeResult,
|
||||||
|
isSaving,
|
||||||
|
isRecomputing,
|
||||||
|
onUpdateWeight,
|
||||||
|
onToggleRole,
|
||||||
|
onRecompute,
|
||||||
|
onSave,
|
||||||
|
}: ValueScorePanelProps) {
|
||||||
|
return (
|
||||||
|
<div className={PANEL_STRONG_CLASS}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wider text-gray-800 dark:text-gray-200">
|
||||||
|
Value Score
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs leading-relaxed text-gray-500 dark:text-gray-400">
|
||||||
|
A persistent 0–100 price/quality metric per resource across five weighted dimensions.
|
||||||
|
Recompute on demand after changing weights or importing new skill matrices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-[11px] text-gray-700 dark:border-gray-600 dark:bg-gray-700/50 dark:text-gray-300">
|
||||||
|
round(<span className="text-brand-600 dark:text-brand-400">D</span>·w₁ +{" "}
|
||||||
|
<span className="text-brand-600 dark:text-brand-400">B</span>·w₂ +{" "}
|
||||||
|
<span className="text-brand-600 dark:text-brand-400">C</span>·w₃ +{" "}
|
||||||
|
<span className="text-brand-600 dark:text-brand-400">A</span>·w₄ +{" "}
|
||||||
|
<span className="text-brand-600 dark:text-brand-400">E</span>·w₅)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
Dimension Weights — must sum to 100%
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||||
|
{WEIGHT_DEFINITIONS.map((definition) => {
|
||||||
|
const value = Math.round(scoreWeights[definition.key] * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={definition.key}
|
||||||
|
className="rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="rounded bg-brand-50 px-1.5 py-0.5 font-mono text-[10px] font-bold text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
|
||||||
|
{definition.code}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-sm font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{definition.title}
|
||||||
|
</span>
|
||||||
|
<span className="w-10 text-right font-mono text-sm font-bold tabular-nums text-brand-600 dark:text-brand-400">
|
||||||
|
{value}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onUpdateWeight(definition.key, Number(event.target.value) / 100)}
|
||||||
|
className="w-full accent-brand-600"
|
||||||
|
/>
|
||||||
|
<details className="mt-1.5">
|
||||||
|
<summary className="cursor-pointer select-none text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
Show details
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
<p className="text-xs leading-relaxed text-gray-600 dark:text-gray-400">
|
||||||
|
{definition.details}
|
||||||
|
</p>
|
||||||
|
<div className="rounded bg-gray-50 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
{definition.formula}
|
||||||
|
<span className="ml-2 text-gray-400">{definition.example}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||||
|
{definition.range}
|
||||||
|
</div>
|
||||||
|
{definition.warning ? (
|
||||||
|
<p className="text-[11px] text-amber-600 dark:text-amber-500">
|
||||||
|
{definition.warning}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-medium ${
|
||||||
|
weightSumOk
|
||||||
|
? "border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300"
|
||||||
|
: "border-red-200 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-900/20 dark:text-red-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{weightSumOk ? "✓" : "✗"}</span>
|
||||||
|
<span>
|
||||||
|
Total weight: <span className="font-mono">{Math.round(weightSum * 100)}%</span>
|
||||||
|
{weightSumOk ? " — valid" : " — must be exactly 100% to save"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={LABEL_CLASS}>Score visibility — which roles can see the Value Score</label>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Controls who sees the score column on the Resources list, the breakdown on the Resource
|
||||||
|
Detail page, and the Top Value Resources dashboard widget.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-3">
|
||||||
|
{ALL_ROLES.map((role) => (
|
||||||
|
<label key={role} className={`${CHECKBOX_ROW_CLASS} cursor-pointer select-none`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={scoreVisibleRoles.includes(role)}
|
||||||
|
onChange={() => onToggleRole(role)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
{role}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 border-t border-gray-100 pt-5 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">
|
||||||
|
Recompute Scores
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Scores are not updated automatically. Run this after changing weights or after importing
|
||||||
|
new skill matrices.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRecompute}
|
||||||
|
disabled={isRecomputing}
|
||||||
|
className={SECONDARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{isRecomputing ? "Recomputing…" : "Recompute All Scores"}
|
||||||
|
</button>
|
||||||
|
{recomputeResult ? (
|
||||||
|
<span className="text-sm font-medium text-green-600 dark:text-green-400">
|
||||||
|
Updated {recomputeResult.updated} resource
|
||||||
|
{recomputeResult.updated !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving || !weightSumOk}
|
||||||
|
className={PRIMARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save Score Settings"}
|
||||||
|
</button>
|
||||||
|
{scoreSaved ? (
|
||||||
|
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export const INPUT_CLASS = "app-input";
|
||||||
|
export const LABEL_CLASS = "app-label";
|
||||||
|
export const PANEL_CLASS = "app-surface p-6 space-y-5";
|
||||||
|
export const PANEL_STRONG_CLASS = "app-surface-strong p-6 space-y-6";
|
||||||
|
export const PRIMARY_BUTTON_CLASS =
|
||||||
|
"rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50";
|
||||||
|
export const SECONDARY_BUTTON_CLASS =
|
||||||
|
"rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800";
|
||||||
|
export 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";
|
||||||
|
|
||||||
|
export type Provider = "openai" | "azure";
|
||||||
|
export type ImageProvider = "dalle" | "gemini";
|
||||||
|
export type RuntimeSecretSource = "environment" | "database" | "none";
|
||||||
|
export type UrlParsedType = "completions" | "responses" | null;
|
||||||
|
|
||||||
|
export type RuntimeSecretStatus = {
|
||||||
|
configured: boolean;
|
||||||
|
activeSource: RuntimeSecretSource;
|
||||||
|
hasStoredValue: boolean;
|
||||||
|
envVarNames: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeSecrets = {
|
||||||
|
azureOpenAiApiKey: RuntimeSecretStatus;
|
||||||
|
azureDalleApiKey: RuntimeSecretStatus;
|
||||||
|
geminiApiKey: RuntimeSecretStatus;
|
||||||
|
smtpPassword: RuntimeSecretStatus;
|
||||||
|
anonymizationSeed: RuntimeSecretStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedAzureUrl = {
|
||||||
|
endpoint: string;
|
||||||
|
apiVersion: string;
|
||||||
|
deployment: string | null;
|
||||||
|
urlType: "completions" | "responses";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SaveResult = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeminiTestResult = {
|
||||||
|
ok: boolean;
|
||||||
|
model?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const;
|
||||||
|
export type SystemRole = (typeof ALL_ROLES)[number];
|
||||||
|
|
||||||
|
export interface ScoreWeights {
|
||||||
|
skillDepth: number;
|
||||||
|
skillBreadth: number;
|
||||||
|
costEfficiency: number;
|
||||||
|
chargeability: number;
|
||||||
|
experience: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAzureUrl(raw: string): ParsedAzureUrl | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
const endpoint = `${url.protocol}//${url.host}`;
|
||||||
|
const apiVersion = url.searchParams.get("api-version") ?? "2025-01-01-preview";
|
||||||
|
const completionsMatch = url.pathname.match(/\/openai\/deployments\/([^/]+)\//);
|
||||||
|
|
||||||
|
if (completionsMatch) {
|
||||||
|
return { endpoint, apiVersion, deployment: completionsMatch[1]!, urlType: "completions" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.includes("/openai/responses")) {
|
||||||
|
return { endpoint, apiVersion, deployment: null, urlType: "responses" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuntimeSecretCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
secret,
|
||||||
|
optionalNote,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: ReactNode;
|
||||||
|
secret: RuntimeSecretStatus;
|
||||||
|
optionalNote?: ReactNode;
|
||||||
|
}) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,11 +21,12 @@
|
|||||||
- `resource` is now onboarded as the second real comment entity, reusing the same ownership and staff-visibility rules as the resource detail route
|
- `resource` is now onboarded as the second real comment entity, reusing the same ownership and staff-visibility rules as the resource detail route
|
||||||
- comment mention autocomplete now uses a dedicated entity-scoped API route instead of inheriting the narrower `user.listAssignable` audience
|
- comment mention autocomplete now uses a dedicated entity-scoped API route instead of inheriting the narrower `user.listAssignable` audience
|
||||||
- runtime secret handling is now environment-first end to end: admin updates no longer persist new operational secrets, runtime status is surfaced explicitly, and legacy database secret copies can be cleared through a dedicated cleanup path
|
- runtime secret handling is now environment-first end to end: admin updates no longer persist new operational secrets, runtime status is surfaced explicitly, and legacy database secret copies can be cleared through a dedicated cleanup path
|
||||||
|
- `apps/web` system settings UI is now decomposed into section components with shared secret/runtime helpers, bringing all files in that slice back under the file-size guardrail
|
||||||
|
|
||||||
## Next Up
|
## Next Up
|
||||||
|
|
||||||
No queued hardening slice is currently pinned in this document.
|
Pin the next structural cleanup on the API side:
|
||||||
Reassess after the current batch so the next item reflects the then-real highest-risk gap instead of stale cleanup residue.
|
split `packages/api/src/router/assistant-tools.ts` into domain-oriented tool modules without changing the public tool contract.
|
||||||
|
|
||||||
## Remaining Major Themes
|
## Remaining Major Themes
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ The small hardening slices are effectively exhausted.
|
|||||||
The remaining work is now structural rather than another quick batch:
|
The remaining work is now structural rather than another quick batch:
|
||||||
|
|
||||||
1. secrets and runtime configuration policy
|
1. secrets and runtime configuration policy
|
||||||
2. oversized router and UI decomposition
|
2. oversized router decomposition
|
||||||
3. production-grade rate limiting
|
3. production-grade rate limiting
|
||||||
4. canonical image-based production delivery
|
4. canonical image-based production delivery
|
||||||
5. performance hotspot reduction
|
5. performance hotspot reduction
|
||||||
|
|||||||
Reference in New Issue
Block a user