refactor(admin): split system settings into section modules

This commit is contained in:
2026-03-30 20:04:06 +02:00
parent a19d2cbae0
commit a36bca7ca7
11 changed files with 1753 additions and 1386 deletions
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 0100. 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 0100 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>
);
}
+4 -3
View File
@@ -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
- 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
- `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
No queued hardening slice is currently pinned in this document.
Reassess after the current batch so the next item reflects the then-real highest-risk gap instead of stale cleanup residue.
Pin the next structural cleanup on the API side:
split `packages/api/src/router/assistant-tools.ts` into domain-oriented tool modules without changing the public tool contract.
## 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:
1. secrets and runtime configuration policy
2. oversized router and UI decomposition
2. oversized router decomposition
3. production-grade rate limiting
4. canonical image-based production delivery
5. performance hotspot reduction