initial commit after split
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user