From a36bca7ca79319f331258ddece9d9675cac66f88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 20:04:06 +0200 Subject: [PATCH] refactor(admin): split system settings into section modules --- .../components/admin/SystemSettingsClient.tsx | 1616 +++-------------- .../system-settings/AiSettingsPanels.tsx | 405 +++++ .../AnonymizationSettingsPanel.tsx | 110 ++ .../system-settings/ImageGenerationPanel.tsx | 216 +++ .../LegacyRuntimeSecretsNotice.tsx | 55 + .../system-settings/SmtpSettingsPanel.tsx | 186 ++ .../system-settings/TimelineSettingsPanel.tsx | 61 + .../system-settings/VacationSettingsPanel.tsx | 66 + .../admin/system-settings/ValueScorePanel.tsx | 254 +++ .../admin/system-settings/shared.tsx | 163 ++ docs/architecture-hardening-backlog.md | 7 +- 11 files changed, 1753 insertions(+), 1386 deletions(-) create mode 100644 apps/web/src/components/admin/system-settings/AiSettingsPanels.tsx create mode 100644 apps/web/src/components/admin/system-settings/AnonymizationSettingsPanel.tsx create mode 100644 apps/web/src/components/admin/system-settings/ImageGenerationPanel.tsx create mode 100644 apps/web/src/components/admin/system-settings/LegacyRuntimeSecretsNotice.tsx create mode 100644 apps/web/src/components/admin/system-settings/SmtpSettingsPanel.tsx create mode 100644 apps/web/src/components/admin/system-settings/TimelineSettingsPanel.tsx create mode 100644 apps/web/src/components/admin/system-settings/VacationSettingsPanel.tsx create mode 100644 apps/web/src/components/admin/system-settings/ValueScorePanel.tsx create mode 100644 apps/web/src/components/admin/system-settings/shared.tsx diff --git a/apps/web/src/components/admin/SystemSettingsClient.tsx b/apps/web/src/components/admin/SystemSettingsClient.tsx index 7114494..1558043 100644 --- a/apps/web/src/components/admin/SystemSettingsClient.tsx +++ b/apps/web/src/components/admin/SystemSettingsClient.tsx @@ -1,152 +1,27 @@ "use client"; -import { useState, useEffect } from "react"; -import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; +import { useEffect, useState } from "react"; import { trpc } from "~/lib/trpc/client.js"; - -const INPUT_CLASS = "app-input"; -const LABEL_CLASS = "app-label"; -const PANEL_CLASS = "app-surface p-6 space-y-5"; -const PANEL_STRONG_CLASS = "app-surface-strong p-6 space-y-6"; -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"; -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"; -const CHECKBOX_ROW_CLASS = - "flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300"; - -type Provider = "openai" | "azure"; -type RuntimeSecretSource = "environment" | "database" | "none"; -type RuntimeSecretStatus = { - configured: boolean; - activeSource: RuntimeSecretSource; - hasStoredValue: boolean; - envVarNames: string[]; -}; - -const ALL_ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const; -type SystemRole = (typeof ALL_ROLES)[number]; - -interface ScoreWeights { - skillDepth: number; - skillBreadth: number; - costEfficiency: number; - chargeability: number; - experience: number; -} - -type ParsedAzureUrl = { - endpoint: string; - apiVersion: string; - deployment: string | null; // null for Responses API URLs (deployment not in path) - urlType: "completions" | "responses"; -}; - -/** Parse endpoint, deployment, and api-version out of an Azure URL. - * Supports both Chat Completions and Responses API formats. */ -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"; - - // Chat Completions: /openai/deployments/{name}/chat/completions - const completionsMatch = url.pathname.match(/\/openai\/deployments\/([^/]+)\//); - if (completionsMatch) { - return { endpoint, apiVersion, deployment: completionsMatch[1]!, urlType: "completions" }; - } - - // Responses API: /openai/responses - 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"; -} - -function RuntimeSecretCard({ - title, - description, - secret, - optionalNote, -}: { - title: string; - description: string; - secret: RuntimeSecretStatus; - optionalNote?: string; -}) { - return ( -
-
-
-

{title}

-

- {description} -

-
- - {getSecretStatusLabel(secret.activeSource)} - -
- -
-

- Runtime status:{" "} - - {secret.configured ? "configured" : "not configured"} - -

-

- Provision via{" "} - {secret.envVarNames.map((name) => ( - - {name} - - ))} -

- {optionalNote ?

{optionalNote}

: null} - {secret.activeSource === "environment" && secret.hasStoredValue ? ( -

- An older database value still exists, but the environment value currently overrides it. -

- ) : null} - {secret.activeSource === "database" ? ( -

- Runtime currently still depends on a legacy database secret. Migrate it to deployment - secrets and clear the stored value afterwards. -

- ) : null} - {secret.activeSource === "none" ? ( -

- No runtime secret is available yet. The related integration will stay disabled or fail - connectivity checks until the deployment secret is set. -

- ) : null} -
-
- ); -} +import { + AiProviderPanel, + GenerationSettingsPanel, +} from "./system-settings/AiSettingsPanels.js"; +import { LegacyRuntimeSecretsNotice } from "./system-settings/LegacyRuntimeSecretsNotice.js"; +import { + type ImageProvider, + type Provider, + type ScoreWeights, + type SaveResult, + type SystemRole, + type UrlParsedType, +} from "./system-settings/shared.js"; +import { AnonymizationSettingsPanel } from "./system-settings/AnonymizationSettingsPanel.js"; +import { ImageGenerationPanel } from "./system-settings/ImageGenerationPanel.js"; +import { SmtpSettingsPanel } from "./system-settings/SmtpSettingsPanel.js"; +import { TimelineSettingsPanel } from "./system-settings/TimelineSettingsPanel.js"; +import { VacationSettingsPanel } from "./system-settings/VacationSettingsPanel.js"; +import { ValueScorePanel } from "./system-settings/ValueScorePanel.js"; +import { parseAzureUrl, type GeminiTestResult } from "./system-settings/shared.js"; export function SystemSettingsClient() { const [provider, setProvider] = useState("openai"); @@ -157,15 +32,10 @@ export function SystemSettingsClient() { const [temperature, setTemperature] = useState(1); const [summaryPrompt, setSummaryPrompt] = useState(""); const [saved, setSaved] = useState(false); - const [testResult, setTestResult] = useState<{ - ok: boolean; - error?: string; - } | null>(null); + const [testResult, setTestResult] = useState(null); const [urlPasteValue, setUrlPasteValue] = useState(""); const [urlParseError, setUrlParseError] = useState(false); - const [urlParsedType, setUrlParsedType] = useState<"completions" | "responses" | null>(null); - - // Value Score settings + const [urlParsedType, setUrlParsedType] = useState(null); const [scoreWeights, setScoreWeights] = useState({ skillDepth: 0.3, skillBreadth: 0.15, @@ -176,41 +46,27 @@ export function SystemSettingsClient() { const [scoreVisibleRoles, setScoreVisibleRoles] = useState(["ADMIN", "MANAGER"]); const [scoreSaved, setScoreSaved] = useState(false); const [recomputeResult, setRecomputeResult] = useState<{ updated: number } | null>(null); - - // DALL-E settings const [dalleDeployment, setDalleDeployment] = useState(""); const [dalleEndpoint, setDalleEndpoint] = useState(""); - - // Gemini / Image generation settings - type ImageProvider = "dalle" | "gemini"; const [imageProvider, setImageProvider] = useState("dalle"); const [geminiModel, setGeminiModel] = useState(""); const [imageSaved, setImageSaved] = useState(false); - - // SMTP settings const [smtpHost, setSmtpHost] = useState(""); const [smtpPort, setSmtpPort] = useState(587); const [smtpUser, setSmtpUser] = useState(""); const [smtpFrom, setSmtpFrom] = useState(""); const [smtpTls, setSmtpTls] = useState(true); const [smtpSaved, setSmtpSaved] = useState(false); - const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; error?: string } | null>( - null, - ); - - // Global anonymization + const [smtpTestResult, setSmtpTestResult] = useState(null); const [anonymizationEnabled, setAnonymizationEnabled] = useState(false); const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de"); const [anonymizationSaved, setAnonymizationSaved] = useState(false); - - // Vacation defaults const [vacationDefaultDays, setVacationDefaultDays] = useState(28); const [vacationSaved, setVacationSaved] = useState(false); - - // Timeline const [undoMaxSteps, setUndoMaxSteps] = useState(50); const [timelineSaved, setTimelineSaved] = useState(false); const [legacyCleanupResult, setLegacyCleanupResult] = useState(null); + const [geminiTestResult, setGeminiTestResult] = useState(null); const utils = trpc.useUtils(); const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, { @@ -218,40 +74,36 @@ export function SystemSettingsClient() { }); useEffect(() => { - if (settings) { - setProvider((settings.aiProvider ?? "openai") as Provider); - setEndpoint(settings.azureOpenAiEndpoint ?? ""); - setModel(settings.azureOpenAiDeployment ?? ""); - setApiVersion(settings.azureApiVersion ?? "2025-01-01-preview"); - setMaxTokens(settings.aiMaxCompletionTokens ?? 2000); - setTemperature(settings.aiTemperature ?? 1); - setSummaryPrompt(settings.aiSummaryPrompt ?? ""); - if (settings.scoreWeights) { - setScoreWeights(settings.scoreWeights as ScoreWeights); - } - if (settings.scoreVisibleRoles) { - setScoreVisibleRoles(settings.scoreVisibleRoles as SystemRole[]); - } - // DALL-E - setDalleDeployment(settings.azureDalleDeployment ?? ""); - setDalleEndpoint(settings.azureDalleEndpoint ?? ""); - // Image provider / Gemini - setImageProvider((settings.imageProvider ?? "dalle") as ImageProvider); - setGeminiModel(settings.geminiModel ?? ""); - // SMTP - setSmtpHost(settings.smtpHost ?? ""); - setSmtpPort(settings.smtpPort ?? 587); - setSmtpUser(settings.smtpUser ?? ""); - setSmtpFrom(settings.smtpFrom ?? ""); - setSmtpTls(settings.smtpTls ?? true); - // Global anonymization - setAnonymizationEnabled(settings.anonymizationEnabled ?? false); - setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de"); - // Vacation - setVacationDefaultDays(settings.vacationDefaultDays ?? 28); - // Timeline - setUndoMaxSteps(settings.timelineUndoMaxSteps ?? 50); + if (!settings) { + return; } + + setProvider((settings.aiProvider ?? "openai") as Provider); + setEndpoint(settings.azureOpenAiEndpoint ?? ""); + setModel(settings.azureOpenAiDeployment ?? ""); + setApiVersion(settings.azureApiVersion ?? "2025-01-01-preview"); + setMaxTokens(settings.aiMaxCompletionTokens ?? 2000); + setTemperature(settings.aiTemperature ?? 1); + setSummaryPrompt(settings.aiSummaryPrompt ?? ""); + if (settings.scoreWeights) { + setScoreWeights(settings.scoreWeights as ScoreWeights); + } + if (settings.scoreVisibleRoles) { + setScoreVisibleRoles(settings.scoreVisibleRoles as SystemRole[]); + } + setDalleDeployment(settings.azureDalleDeployment ?? ""); + setDalleEndpoint(settings.azureDalleEndpoint ?? ""); + setImageProvider((settings.imageProvider ?? "dalle") as ImageProvider); + setGeminiModel(settings.geminiModel ?? ""); + setSmtpHost(settings.smtpHost ?? ""); + setSmtpPort(settings.smtpPort ?? 587); + setSmtpUser(settings.smtpUser ?? ""); + setSmtpFrom(settings.smtpFrom ?? ""); + setSmtpTls(settings.smtpTls ?? true); + setAnonymizationEnabled(settings.anonymizationEnabled ?? false); + setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de"); + setVacationDefaultDays(settings.vacationDefaultDays ?? 28); + setUndoMaxSteps(settings.timelineUndoMaxSteps ?? 50); }, [settings]); function invalidateSystemSettings() { @@ -265,18 +117,22 @@ export function SystemSettingsClient() { setUrlParsedType(null); return; } + const parsed = parseAzureUrl(raw); - if (parsed) { - setEndpoint(parsed.endpoint); - setApiVersion(parsed.apiVersion); - if (parsed.deployment) setModel(parsed.deployment); - setUrlParseError(false); - setUrlParsedType(parsed.urlType); - setUrlPasteValue(""); - } else { + if (!parsed) { setUrlParseError(true); setUrlParsedType(null); + return; } + + setEndpoint(parsed.endpoint); + setApiVersion(parsed.apiVersion); + if (parsed.deployment) { + setModel(parsed.deployment); + } + setUrlParseError(false); + setUrlParsedType(parsed.urlType); + setUrlPasteValue(""); } const updateMutation = trpc.settings.updateSystemSettings.useMutation({ @@ -291,7 +147,7 @@ export function SystemSettingsClient() { const testMutation = trpc.settings.testAiConnection.useMutation({ onSuccess: (data) => setTestResult(data), - onError: (err) => setTestResult({ ok: false, error: err.message }), + onError: (error) => setTestResult({ ok: false, error: error.message }), }); const saveScoreMutation = trpc.settings.updateSystemSettings.useMutation({ @@ -318,7 +174,7 @@ export function SystemSettingsClient() { const testSmtpMutation = trpc.settings.testSmtpConnection.useMutation({ onSuccess: (data) => setSmtpTestResult(data), - onError: (err) => setSmtpTestResult({ ok: false, error: err.message }), + onError: (error) => setSmtpTestResult({ ok: false, error: error.message }), }); const saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({ @@ -364,17 +220,39 @@ export function SystemSettingsClient() { ); invalidateSystemSettings(); }, - onError: (error) => { - setLegacyCleanupResult(error.message); - }, + onError: (error) => setLegacyCleanupResult(error.message), }); - const [geminiTestResult, setGeminiTestResult] = useState<{ ok: boolean; model?: string; error?: string } | null>(null); - const testGeminiMut = trpc.settings.testGeminiConnection.useMutation({ - onSuccess: (data) => setGeminiTestResult(data as any), - onError: (err) => setGeminiTestResult({ ok: false, error: err.message }), + const testGeminiMutation = trpc.settings.testGeminiConnection.useMutation({ + onSuccess: (data) => setGeminiTestResult(data as GeminiTestResult), + onError: (error) => setGeminiTestResult({ ok: false, error: error.message }), }); + function handleSave() { + updateMutation.mutate({ + aiProvider: provider, + azureOpenAiEndpoint: provider === "azure" ? endpoint : "", + azureOpenAiDeployment: model, + azureApiVersion: provider === "azure" ? apiVersion : undefined, + aiMaxCompletionTokens: maxTokens, + aiTemperature: temperature, + aiSummaryPrompt: summaryPrompt || undefined, + }); + } + + function handleSaveScoreSettings() { + saveScoreMutation.mutate({ scoreWeights, scoreVisibleRoles }); + } + + function handleSaveImage() { + saveImageMutation.mutate({ + imageProvider, + azureDalleDeployment: dalleDeployment || undefined, + azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined, + geminiModel: geminiModel || undefined, + }); + } + function handleSaveSmtp() { saveSmtpMutation.mutate({ smtpHost: smtpHost || undefined, @@ -393,17 +271,6 @@ export function SystemSettingsClient() { saveTimelineMutation.mutate({ timelineUndoMaxSteps: undoMaxSteps }); } - function handleSaveImage() { - saveImageMutation.mutate({ - imageProvider, - // DALL-E fields - azureDalleDeployment: dalleDeployment || undefined, - azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined, - // Gemini fields - geminiModel: geminiModel || undefined, - }); - } - function handleSaveAnonymization() { saveAnonymizationMutation.mutate({ anonymizationEnabled, @@ -412,35 +279,16 @@ export function SystemSettingsClient() { }); } - function handleSaveScoreSettings() { - saveScoreMutation.mutate({ scoreWeights, scoreVisibleRoles }); - } - function updateWeight(key: keyof ScoreWeights, value: number) { - setScoreWeights((prev) => ({ ...prev, [key]: value })); + setScoreWeights((previous) => ({ ...previous, [key]: value })); } function toggleRole(role: SystemRole) { - setScoreVisibleRoles((prev) => - prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role], + setScoreVisibleRoles((previous) => + previous.includes(role) ? previous.filter((entry) => entry !== role) : [...previous, role], ); } - const weightSum = Object.values(scoreWeights).reduce((s, v) => s + v, 0); - const weightSumOk = Math.abs(weightSum - 1.0) < 0.01; - - function handleSave() { - updateMutation.mutate({ - aiProvider: provider, - azureOpenAiEndpoint: provider === "azure" ? endpoint : "", - azureOpenAiDeployment: model, - azureApiVersion: provider === "azure" ? apiVersion : undefined, - aiMaxCompletionTokens: maxTokens, - aiTemperature: temperature, - aiSummaryPrompt: summaryPrompt || undefined, - }); - } - function handleClearLegacyRuntimeSecrets() { if ( typeof window !== "undefined" @@ -454,10 +302,13 @@ export function SystemSettingsClient() { clearRuntimeSecretsMutation.mutate(); } + const weightSum = Object.values(scoreWeights).reduce((sum, value) => sum + value, 0); + const weightSumOk = Math.abs(weightSum - 1.0) < 0.01; + if (isLoading) { return (
-
+
); } @@ -483,1141 +334,140 @@ export function SystemSettingsClient() {
- {settings.legacyStoredSecretFields.length ? ( -
-
-
-

- Legacy Runtime Secrets Detected -

-

- 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. -

-

- Affected fields:{" "} - {settings.legacyStoredSecretFields.map((field) => ( - - {field} - - ))} -

- {legacyCleanupResult ? ( -

- {legacyCleanupResult} -

- ) : null} -
- -
-
- ) : null} + -
-
-

- AI Provider -

- - {/* Provider toggle */} -
- -
- - -
-

- {provider === "openai" - ? "Use a standard OpenAI API key from platform.openai.com." - : "Use a deployment on your own Azure OpenAI resource."} -

-
- - {/* Azure-only fields */} - {provider === "azure" && ( - <> - {/* Paste full URL shortcut */} -
-

- Paste a full completion URL to auto-fill all fields below: -

- handleUrlPaste(e.target.value)} - /> - {urlParseError && ( -

- Could not parse URL — expected either a Chat Completions URL ( - /openai/deployments/…/chat/completions) or a - Responses API URL (/openai/responses). -

- )} - {urlParsedType === "responses" && ( -

- Responses API URL detected — endpoint and api-version filled in. Enter the{" "} - deployment/model name manually below (it is not part of this - URL). -

- )} - {urlParsedType === "completions" && ( -

- All fields filled from URL. -

- )} -
- -
- - setEndpoint(e.target.value)} - /> -

- Everything up to (not including) /openai/… -

-
- - )} - - {/* Model / deployment name */} -
- - setModel(e.target.value)} - /> -

- {provider === "azure" - ? "The deployment name you chose when deploying the model in Azure." - : "The model identifier, e.g. gpt-4o-mini, gpt-4o, gpt-3.5-turbo."} -

-
- - {/* Azure-only: api version */} - {provider === "azure" && ( -
- - setApiVersion(e.target.value)} - /> -

- The api-version query parameter from your - endpoint URL. Default: 2025-01-01-preview -

-
- )} - - - - {/* Test result */} - {testResult && ( -
- {testResult.ok ? ( - - Connection successful — AI summaries are ready to use. - - ) : ( -
-

- Connection failed: {testResult.error} -

-
- )} -
- )} - -
- - - {saved && ( - Saved! - )} -
-
- - {/* Generation settings */} -
-

- Generation Settings -

- - {/* Max completion tokens */} -
-
- - - {maxTokens} - -
- setMaxTokens(Number(e.target.value))} - className="w-full accent-brand-600" - /> -
- 500 - - {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"} - - 16000 -
-

- Reasoning models (GPT-5, o1, o3) consume tokens internally before writing output. Set - to at least 2000 to avoid empty responses. -

-
- - {/* Temperature */} -
-
- - - {temperature.toFixed(1)} - -
- setTemperature(Number(e.target.value))} - className="w-full accent-brand-600" - /> -
- 0 — deterministic - - {temperature <= 0.3 - ? "Factual & consistent" - : temperature <= 0.9 - ? "Balanced" - : temperature <= 1.0 - ? "Default (1) — recommended ✓" - : temperature <= 1.2 - ? "Creative" - : "Very creative / unpredictable"} - - 2 — creative -
-

- Some models (e.g. GPT-5) only accept the default value of 1. If generation fails with - a temperature error, the system retries automatically without it. -

-
- - {/* Summary prompt */} -
-
- - {summaryPrompt && ( - - )} -
-