Files
CapaKraken/apps/web/src/components/admin/SystemSettingsClient.tsx
T

474 lines
16 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
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<Provider>("openai");
const [endpoint, setEndpoint] = useState("");
const [model, setModel] = useState("");
const [apiVersion, setApiVersion] = useState("2025-01-01-preview");
const [maxTokens, setMaxTokens] = useState(2000);
const [temperature, setTemperature] = useState(1);
const [summaryPrompt, setSummaryPrompt] = useState("");
const [saved, setSaved] = useState(false);
const [testResult, setTestResult] = useState<SaveResult | null>(null);
const [urlPasteValue, setUrlPasteValue] = useState("");
const [urlParseError, setUrlParseError] = useState(false);
const [urlParsedType, setUrlParsedType] = useState<UrlParsedType>(null);
const [scoreWeights, setScoreWeights] = useState<ScoreWeights>({
skillDepth: 0.3,
skillBreadth: 0.15,
costEfficiency: 0.25,
chargeability: 0.15,
experience: 0.15,
});
const [scoreVisibleRoles, setScoreVisibleRoles] = useState<SystemRole[]>(["ADMIN", "MANAGER"]);
const [scoreSaved, setScoreSaved] = useState(false);
const [recomputeResult, setRecomputeResult] = useState<{ updated: number } | null>(null);
const [dalleDeployment, setDalleDeployment] = useState("");
const [dalleEndpoint, setDalleEndpoint] = useState("");
const [imageProvider, setImageProvider] = useState<ImageProvider>("dalle");
const [geminiModel, setGeminiModel] = useState("");
const [imageSaved, setImageSaved] = useState(false);
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<SaveResult | null>(null);
const [anonymizationEnabled, setAnonymizationEnabled] = useState(false);
const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de");
const [anonymizationSaved, setAnonymizationSaved] = useState(false);
const [vacationDefaultDays, setVacationDefaultDays] = useState(28);
const [vacationSaved, setVacationSaved] = useState(false);
const [undoMaxSteps, setUndoMaxSteps] = useState(50);
const [timelineSaved, setTimelineSaved] = useState(false);
const [legacyCleanupResult, setLegacyCleanupResult] = useState<string | null>(null);
const [geminiTestResult, setGeminiTestResult] = useState<GeminiTestResult | null>(null);
const utils = trpc.useUtils();
const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, {
staleTime: 0,
});
useEffect(() => {
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() {
void utils.settings.getSystemSettings.invalidate();
}
function handleUrlPaste(raw: string) {
setUrlPasteValue(raw);
if (!raw) {
setUrlParseError(false);
setUrlParsedType(null);
return;
}
const parsed = parseAzureUrl(raw);
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({
onSuccess: () => {
setSaved(true);
setTestResult(null);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setSaved(false), 3000);
},
});
const testMutation = trpc.settings.testAiConnection.useMutation({
onSuccess: (data) => setTestResult(data),
onError: (error) => setTestResult({ ok: false, error: error.message }),
});
const saveScoreMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setScoreSaved(true);
invalidateSystemSettings();
setTimeout(() => setScoreSaved(false), 3000);
},
});
const recomputeMutation = trpc.resource.recomputeValueScores.useMutation({
onSuccess: (data) => setRecomputeResult(data),
});
const saveSmtpMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setSmtpSaved(true);
setSmtpTestResult(null);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setSmtpSaved(false), 3000);
},
});
const testSmtpMutation = trpc.settings.testSmtpConnection.useMutation({
onSuccess: (data) => setSmtpTestResult(data),
onError: (error) => setSmtpTestResult({ ok: false, error: error.message }),
});
const saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setAnonymizationSaved(true);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setAnonymizationSaved(false), 3000);
},
});
const saveVacationMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setVacationSaved(true);
invalidateSystemSettings();
setTimeout(() => setVacationSaved(false), 3000);
},
});
const saveTimelineMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setTimelineSaved(true);
invalidateSystemSettings();
setTimeout(() => setTimelineSaved(false), 3000);
},
});
const saveImageMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setImageSaved(true);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setImageSaved(false), 3000);
},
});
const clearRuntimeSecretsMutation = trpc.settings.clearStoredRuntimeSecrets.useMutation({
onSuccess: (data) => {
setLegacyCleanupResult(
data.clearedFields.length > 0
? `Cleared ${data.clearedFields.length} legacy database secret field${data.clearedFields.length === 1 ? "" : "s"}.`
: "No legacy database secrets were left to clear.",
);
invalidateSystemSettings();
},
onError: (error) => setLegacyCleanupResult(error.message),
});
const 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,
smtpPort,
smtpUser: smtpUser || undefined,
smtpFrom: smtpFrom || undefined,
smtpTls,
});
}
function handleSaveVacation() {
saveVacationMutation.mutate({ vacationDefaultDays });
}
function handleSaveTimeline() {
saveTimelineMutation.mutate({ timelineUndoMaxSteps: undoMaxSteps });
}
function handleSaveAnonymization() {
saveAnonymizationMutation.mutate({
anonymizationEnabled,
anonymizationDomain: anonymizationDomain.trim() || "superhartmut.de",
anonymizationMode: "global",
});
}
function updateWeight(key: keyof ScoreWeights, value: number) {
setScoreWeights((previous) => ({ ...previous, [key]: value }));
}
function toggleRole(role: SystemRole) {
setScoreVisibleRoles((previous) =>
previous.includes(role) ? previous.filter((entry) => entry !== role) : [...previous, role],
);
}
function handleClearLegacyRuntimeSecrets() {
if (
typeof window !== "undefined"
&& !window.confirm(
"Clear all legacy runtime secrets from database storage? Environment-based deployment secrets must already be configured.",
)
) {
return;
}
clearRuntimeSecretsMutation.mutate();
}
const weightSum = Object.values(scoreWeights).reduce((sum, value) => sum + value, 0);
const weightSumOk = Math.abs(weightSum - 1.0) < 0.01;
if (isLoading) {
return (
<div className="app-page">
<div className="h-8 w-48 rounded shimmer-skeleton" />
</div>
);
}
if (!settings) {
return (
<div className="app-page">
<div className="rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300">
System settings could not be loaded.
</div>
</div>
);
}
return (
<div className="app-page space-y-6">
<div className="app-page-header gap-4">
<div>
<h1 className="app-page-title">System Settings</h1>
<p className="app-page-subtitle mt-1">
Configure AI integration for skill profile generation.
</p>
</div>
</div>
<LegacyRuntimeSecretsNotice
fields={settings.legacyStoredSecretFields}
result={legacyCleanupResult}
isPending={clearRuntimeSecretsMutation.isPending}
onClear={handleClearLegacyRuntimeSecrets}
/>
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-2">
<AiProviderPanel
provider={provider}
endpoint={endpoint}
model={model}
apiVersion={apiVersion}
urlPasteValue={urlPasteValue}
urlParseError={urlParseError}
urlParsedType={urlParsedType}
runtimeSecret={settings.runtimeSecrets.azureOpenAiApiKey}
testResult={testResult}
isSaving={updateMutation.isPending}
isTesting={testMutation.isPending}
saved={saved}
onProviderChange={(nextProvider) => {
setProvider(nextProvider);
setTestResult(null);
}}
onEndpointChange={setEndpoint}
onModelChange={setModel}
onApiVersionChange={setApiVersion}
onUrlPaste={handleUrlPaste}
onSave={handleSave}
onTest={() => {
setTestResult(null);
testMutation.mutate();
}}
/>
<GenerationSettingsPanel
maxTokens={maxTokens}
temperature={temperature}
summaryPrompt={summaryPrompt}
defaultSummaryPrompt={settings.defaultSummaryPrompt ?? ""}
isSaving={updateMutation.isPending}
saved={saved}
onMaxTokensChange={setMaxTokens}
onTemperatureChange={setTemperature}
onSummaryPromptChange={setSummaryPrompt}
onSave={handleSave}
onResetSummaryPrompt={() => setSummaryPrompt("")}
/>
</div>
<ValueScorePanel
scoreWeights={scoreWeights}
scoreVisibleRoles={scoreVisibleRoles}
weightSum={weightSum}
weightSumOk={weightSumOk}
scoreSaved={scoreSaved}
recomputeResult={recomputeResult}
isSaving={saveScoreMutation.isPending}
isRecomputing={recomputeMutation.isPending}
onUpdateWeight={updateWeight}
onToggleRole={toggleRole}
onRecompute={() => {
setRecomputeResult(null);
recomputeMutation.mutate();
}}
onSave={handleSaveScoreSettings}
/>
<ImageGenerationPanel
provider={provider}
imageProvider={imageProvider}
dalleDeployment={dalleDeployment}
dalleEndpoint={dalleEndpoint}
geminiModel={geminiModel}
imageSaved={imageSaved}
geminiTestResult={geminiTestResult}
runtimeSecrets={settings.runtimeSecrets}
isSaving={saveImageMutation.isPending}
isTestingGemini={testGeminiMutation.isPending}
onImageProviderChange={setImageProvider}
onDalleDeploymentChange={setDalleDeployment}
onDalleEndpointChange={setDalleEndpoint}
onGeminiModelChange={setGeminiModel}
onSave={handleSaveImage}
onTestGemini={() => testGeminiMutation.mutate()}
/>
<SmtpSettingsPanel
smtpHost={smtpHost}
smtpPort={smtpPort}
smtpUser={smtpUser}
smtpFrom={smtpFrom}
smtpTls={smtpTls}
smtpSaved={smtpSaved}
smtpTestResult={smtpTestResult}
smtpSecret={settings.runtimeSecrets.smtpPassword}
isSaving={saveSmtpMutation.isPending}
isTesting={testSmtpMutation.isPending}
onSmtpHostChange={setSmtpHost}
onSmtpPortChange={setSmtpPort}
onSmtpUserChange={setSmtpUser}
onSmtpFromChange={setSmtpFrom}
onSmtpTlsChange={setSmtpTls}
onSave={handleSaveSmtp}
onTest={() => testSmtpMutation.mutate()}
/>
<VacationSettingsPanel
vacationDefaultDays={vacationDefaultDays}
vacationSaved={vacationSaved}
isSaving={saveVacationMutation.isPending}
onVacationDefaultDaysChange={setVacationDefaultDays}
onSave={handleSaveVacation}
/>
<TimelineSettingsPanel
undoMaxSteps={undoMaxSteps}
timelineSaved={timelineSaved}
isSaving={saveTimelineMutation.isPending}
onUndoMaxStepsChange={setUndoMaxSteps}
onSave={handleSaveTimeline}
/>
<AnonymizationSettingsPanel
anonymizationEnabled={anonymizationEnabled}
anonymizationDomain={anonymizationDomain}
anonymizationSaved={anonymizationSaved}
anonymizationSecret={settings.runtimeSecrets.anonymizationSeed}
isSaving={saveAnonymizationMutation.isPending}
onAnonymizationEnabledChange={setAnonymizationEnabled}
onAnonymizationDomainChange={setAnonymizationDomain}
onSave={handleSaveAnonymization}
/>
</div>
);
}