initial commit after split

This commit is contained in:
2026-06-24 01:43:10 +02:00
parent b1d6c0150f
commit 30c11a6dcf
173 changed files with 25380 additions and 0 deletions
@@ -0,0 +1,290 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { ApiSettings, AuthInfo } from "../../types";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client";
import { updateProfile } from "../../api/auth";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DismissibleAlert from "../../components/DismissibleAlert";
import { MailProfileScopeManager } from "@govoplan/mail-webui";
import { hasAnyScope, hasScope } from "../../utils/permissions";
type SettingsSection = "profile" | "mail-profiles" | "interface" | "workspace" | "local-connection" | "notifications";
function settingsGroups(canUseMailProfiles: boolean): ModuleSubnavGroup<SettingsSection>[] {
return [
{
title: "ACCOUNT",
items: [
{ id: "profile", label: "My profile" },
...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "Mail profiles" }] : [])
]
},
{
title: "UI SETTINGS",
items: [
{ id: "interface", label: "Interface" },
{ id: "workspace", label: "Workspace" },
{ id: "local-connection", label: "Local connection" },
{ id: "notifications", label: "Notifications" }
]
}
];
}
export default function SettingsPage({
settings,
auth,
onSettingsChange,
onAuthChange
}: {
settings: ApiSettings;
auth: AuthInfo;
onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
}) {
const [searchParams, setSearchParams] = useSearchParams();
const canUseMailProfiles = hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles), [canUseMailProfiles]);
const requestedSection = searchParams.get("section") as SettingsSection | null;
const [active, setActive] = useState<SettingsSection>(settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState("");
const [compactTables, setCompactTables] = useState(false);
const [showHelpHints, setShowHelpHints] = useState(true);
const [reduceMotion, setReduceMotion] = useState(false);
const [stickySections, setStickySections] = useState(true);
const [profileName, setProfileName] = useState(auth.user.display_name || "");
const [tenantProfileName, setTenantProfileName] = useState(auth.user.tenant_display_name || "");
const [profileBusy, setProfileBusy] = useState(false);
const [profileResult, setProfileResult] = useState("");
useEffect(() => {
if (settingsSectionAvailable(settingsSubnav, requestedSection)) {
setActive(requestedSection);
} else if (!settingsSectionAvailable(settingsSubnav, active)) {
setActive("interface");
}
}, [active, requestedSection, settingsSubnav]);
useEffect(() => {
setProfileName(auth.user.display_name || "");
setTenantProfileName(auth.user.tenant_display_name || "");
}, [auth.user.display_name, auth.user.tenant_display_name]);
function selectSection(section: SettingsSection) {
setActive(section);
setSearchParams(section === "interface" ? {} : { section });
}
async function saveProfile() {
setProfileBusy(true);
setProfileResult("");
try {
const next = await updateProfile(settings, {
display_name: profileName.trim() || null,
tenant_display_name: tenantProfileName.trim() || null
});
onAuthChange(next);
setProfileResult("Profile saved. The account menu has been updated.");
} catch (err) {
setProfileResult(err instanceof Error ? err.message : String(err));
} finally {
setProfileBusy(false);
}
}
async function testConnection() {
setTesting(true);
setTestResult("");
try {
await apiFetch<unknown>(settings, "/health");
setTestResult("Connection successful. The backend health endpoint responded.");
} catch (err) {
setTestResult(err instanceof Error ? err.message : String(err));
} finally {
setTesting(false);
}
}
return (
<div className="workspace module-workspace">
<ModuleSubnav active={active} groups={settingsSubnav} onSelect={selectSection} />
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>Settings</PageTitle>
<p>Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.</p>
</div>
</div>
{active === "profile" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="My profile">
<div className="form-grid">
<FormField label="Account display name" help="This global name is shown in the title bar and follows you across tenant memberships.">
<input value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder={auth.user.email} />
</FormField>
<FormField label="Tenant display name" help="Optional local alias for the active tenant. It does not replace the global account name in the title bar.">
<input value={tenantProfileName} onChange={(event) => setTenantProfileName(event.target.value)} placeholder="Use account display name" />
</FormField>
<FormField label="Email">
<input value={auth.user.email} disabled />
</FormField>
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => void saveProfile()} disabled={profileBusy}>{profileBusy ? "Saving…" : "Save profile"}</Button>
</div>
{profileResult && <DismissibleAlert tone={profileResult.startsWith("Profile saved") ? "success" : "warning"} resetKey={profileResult} floating>{profileResult}</DismissibleAlert>}
</div>
</Card>
<Card title="Account context">
<dl className="detail-list compact-detail-list">
<div><dt>Account ID</dt><dd>{auth.user.account_id}</dd></div>
<div><dt>Active tenant</dt><dd>{auth.active_tenant?.name || auth.tenant.name}</dd></div>
<div><dt>Tenant roles</dt><dd>{auth.roles.filter((role) => role.level !== "system").map((role) => role.name).join(", ") || "None"}</dd></div>
<div><dt>System roles</dt><dd>{auth.roles.filter((role) => role.level === "system").map((role) => role.name).join(", ") || "None"}</dd></div>
</dl>
<p className="muted small-note">Email changes and password management require dedicated identity-verification workflows and are intentionally not part of this first self-service profile editor.</p>
</Card>
</div>
)}
{active === "mail-profiles" && (
<MailProfileScopeManager
settings={settings}
scopeType="user"
scopeId={auth.user.id}
profileTitle="My mail server profiles"
policyTitle="My mail profile policy"
canWriteProfiles={hasScope(auth, "mail_servers:write")}
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])}
/>
)}
{active === "interface" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Interface preferences">
<div className="form-grid">
<ToggleSwitch
label="Compact tables"
help="Prepared UI preference for denser tables. The current table layout remains unchanged until this is wired globally."
checked={compactTables}
onChange={setCompactTables}
/>
<ToggleSwitch
label="Show inline help hints"
help="Controls contextual UI help markers once persisted user preferences are available."
checked={showHelpHints}
onChange={setShowHelpHints}
/>
<ToggleSwitch
label="Reduce motion"
help="Prepared preference for users who prefer fewer animations."
checked={reduceMotion}
onChange={setReduceMotion}
/>
</div>
</Card>
<Card title="Theme and language">
<dl className="detail-list compact-detail-list">
<div><dt>Theme</dt><dd>System default for now</dd></div>
<div><dt>Accent color</dt><dd>Default brand accent</dd></div>
<div><dt>Language</dt><dd>Browser/application default</dd></div>
<div><dt>Density</dt><dd>{compactTables ? "Compact preview" : "Comfortable"}</dd></div>
</dl>
</Card>
</div>
)}
{active === "workspace" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Campaign workspace">
<div className="form-grid">
<ToggleSwitch
label="Sticky section sidebars"
help="Keeps campaign and admin section navigation in view while scrolling."
checked={stickySections}
onChange={setStickySections}
/>
<ToggleSwitch
label="Keep page shell visible while loading"
help="The current UI already keeps the section shell visible and overlays loading indicators during refresh."
checked
disabled
onChange={() => undefined}
/>
</div>
</Card>
<Card title="Editor behavior">
<div className="placeholder-stack">
<span>Manual save with unsaved-change guard</span>
<span>Readable chooser fields for file/path selection</span>
<span>Field-type-aware recipient data inputs</span>
<span>Template placeholder chips and preview overlays</span>
</div>
</Card>
</div>
)}
{active === "local-connection" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Local API connection">
<div className="form-grid">
<FormField label="API base URL" help="Leave empty to use the same origin. In Vite dev, /api is proxied to the FastAPI backend.">
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
</FormField>
<FormField label="Automation API key" help="Used only when there is no browser session token. Browser login remains the preferred interactive mode.">
<input type="password" value={settings.apiKey} onChange={(e) => onSettingsChange({ ...settings, apiKey: e.target.value })} />
</FormField>
<div className="button-row compact-actions">
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>
</div>
{testResult && <DismissibleAlert tone={testResult.startsWith("Connection successful") ? "success" : "warning"} resetKey={testResult} floating>{testResult}</DismissibleAlert>}
</div>
</Card>
<Card title="Session state">
<dl className="detail-list compact-detail-list">
<div><dt>Browser session</dt><dd>Active via HttpOnly cookie</dd></div>
<div><dt>Automation key</dt><dd>{settings.apiKey ? "Configured" : "Not configured"}</dd></div>
<div><dt>Backend mode</dt><dd>{settings.apiBaseUrl ? "Explicit API URL" : "Same-origin / proxied"}</dd></div>
</dl>
<p className="muted small-note">Tenant, user, mail-server and policy administration has moved to Admin. This page keeps browser-local configuration.</p>
</Card>
</div>
)}
{active === "notifications" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Notification preferences">
<p className="muted">Prepared for later personal notification preferences. Backend and browser notification wiring are not active yet.</p>
<div className="placeholder-stack">
<span>In-app completion notices</span>
<span>Email summary preferences</span>
<span>Failure and warning alerts</span>
</div>
</Card>
<Card title="Quiet UI mode">
<div className="placeholder-stack">
<span>Mute non-critical banners</span>
<span>Batch repetitive notices</span>
<span>Keep validation and send warnings visible</span>
</div>
</Card>
</div>
)}
</div>
</section>
</div>
);
}
function settingsSectionAvailable(groups: ModuleSubnavGroup<SettingsSection>[], section: SettingsSection | null | undefined): section is SettingsSection {
return Boolean(section && groups.some((group) => group.items.some((item) => "id" in item && item.id === section)));
}