intermittent commit
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo, FilesConnectorsUiCapability, MailProfilesUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
|
||||
import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
@@ -8,15 +8,16 @@ 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 { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import SegmentedControl from "../../components/SegmentedControl";
|
||||
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | "notifications";
|
||||
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
|
||||
|
||||
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
||||
compact_tables: false,
|
||||
@@ -32,8 +33,8 @@ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
|
||||
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
||||
];
|
||||
|
||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean): ModuleSubnavGroup<SettingsSection>[] {
|
||||
return [
|
||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
|
||||
const groups: ModuleSubnavGroup<SettingsSection>[] = [
|
||||
{
|
||||
title: "i18n:govoplan-core.account.f967543b",
|
||||
items: [
|
||||
@@ -47,11 +48,29 @@ function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boole
|
||||
items: [
|
||||
{ id: "interface", label: "i18n:govoplan-core.interface.7b4db7ef" },
|
||||
{ id: "workspace", label: "i18n:govoplan-core.workspace.4ca0a75c" },
|
||||
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" },
|
||||
{ id: "notifications", label: "i18n:govoplan-core.notifications.753a22b2" }]
|
||||
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" }]
|
||||
|
||||
}];
|
||||
|
||||
for (const section of contributedSections) {
|
||||
const groupId = section.group || "ui";
|
||||
const title = section.groupTitle || (groupId === "account" ? "i18n:govoplan-core.account.f967543b" : groupId === "ui" ? "i18n:govoplan-core.ui_settings.9e9cc5ea" : groupId);
|
||||
let group = groups.find((item) => item.title === title);
|
||||
if (!group) {
|
||||
group = { title, items: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.items.push({ id: section.id, label: section.label });
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
group.items.sort((left, right) => {
|
||||
const leftId = "id" in left ? left.id : "";
|
||||
const rightId = "id" in right ? right.id : "";
|
||||
return (sectionOrder(leftId, contributedSections) - sectionOrder(rightId, contributedSections)) || left.label.localeCompare(right.label);
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export default function SettingsPage({
|
||||
@@ -64,19 +83,26 @@ export default function SettingsPage({
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;}) {
|
||||
}: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
|
||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors), [canUseFileConnectors, canUseMailProfiles]);
|
||||
const requestedSection = searchParams.get("section") as SettingsSection | null;
|
||||
const active: SettingsSection = settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface";
|
||||
const contributedSections = useMemo(
|
||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||
[auth, settingsSectionCapabilities]
|
||||
);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, contributedSections), [canUseFileConnectors, canUseMailProfiles, contributedSections]);
|
||||
const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
|
||||
const requestedSection = searchParams.get("section");
|
||||
const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface";
|
||||
const activeContributedSection = contributedSections.find((section) => section.id === active) ?? null;
|
||||
const currentUiPreferences = normalizeUiPreferences(auth.user.ui_preferences);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState("");
|
||||
@@ -118,12 +144,30 @@ export default function SettingsPage({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedSection && !settingsSectionAvailable(settingsSubnav, requestedSection)) {
|
||||
if (requestedSection && !settingsSectionAvailable(availableSectionIds, requestedSection)) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("section", "interface");
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
}, [requestedSection, searchParams, setSearchParams, settingsSubnav]);
|
||||
}, [availableSectionIds, requestedSection, searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.profile_loaded) return;
|
||||
let cancelled = false;
|
||||
fetchAuthProfile(settings)
|
||||
.then((next) => {if (!cancelled) onAuthChange(next);})
|
||||
.catch(() => undefined);
|
||||
return () => {cancelled = true;};
|
||||
}, [auth.profile_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.roles_loaded) return;
|
||||
let cancelled = false;
|
||||
fetchAuthRoles(settings)
|
||||
.then((next) => {if (!cancelled) onAuthChange(next);})
|
||||
.catch(() => undefined);
|
||||
return () => {cancelled = true;};
|
||||
}, [auth.roles_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setProfileName(auth.user.display_name || "");
|
||||
@@ -329,12 +373,16 @@ export default function SettingsPage({
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-core.theme.a797e309">
|
||||
<select value={theme} onChange={(event) => setTheme(event.target.value as UserUiTheme)}>
|
||||
{UI_THEME_OPTIONS.map((item) =>
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
)}
|
||||
</select>
|
||||
<SegmentedControl
|
||||
options={UI_THEME_OPTIONS.map((item) => ({ id: item.value, label: item.label }))}
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
role="group"
|
||||
size="equal"
|
||||
width="fill"
|
||||
ariaLabel="i18n:govoplan-core.theme.a797e309" />
|
||||
</FormField>
|
||||
<ThemePreview theme={theme} />
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div>
|
||||
@@ -413,24 +461,15 @@ export default function SettingsPage({
|
||||
</div>
|
||||
}
|
||||
|
||||
{active === "notifications" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.notification_preferences.0ead6c12">
|
||||
<p className="muted">i18n:govoplan-core.prepared_for_later_personal_notification_prefere.3fe73f86</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>i18n:govoplan-core.in_app_completion_notices.b68f2f4c</span>
|
||||
<span>i18n:govoplan-core.email_summary_preferences.b6c1dfb1</span>
|
||||
<span>i18n:govoplan-core.failure_and_warning_alerts.939d21c2</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-core.quiet_ui_mode.1b0bd558">
|
||||
<div className="placeholder-stack">
|
||||
<span>i18n:govoplan-core.mute_non_critical_banners.27b23a4a</span>
|
||||
<span>i18n:govoplan-core.batch_repetitive_notices.cc893559</span>
|
||||
<span>i18n:govoplan-core.keep_validation_and_send_warnings_visible.9d6e0cf4</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{activeContributedSection &&
|
||||
activeContributedSection.render({
|
||||
settings,
|
||||
auth,
|
||||
onAuthChange,
|
||||
activeSection: active,
|
||||
availableSections: availableSectionIds,
|
||||
selectSection
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -439,8 +478,30 @@ export default function SettingsPage({
|
||||
}
|
||||
|
||||
|
||||
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)));
|
||||
function settingsSectionAvailable(sections: ReadonlySet<string>, section: string | null | undefined): section is SettingsSection {
|
||||
return Boolean(section && sections.has(section));
|
||||
}
|
||||
|
||||
function canUseSettingsContribution(auth: AuthInfo, section: SettingsSectionContribution): boolean {
|
||||
if (section.allOf?.some((scope) => !hasScope(auth, scope))) {
|
||||
return false;
|
||||
}
|
||||
if (section.anyOf && !hasAnyScope(auth, section.anyOf)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sectionOrder(sectionId: string, contributedSections: SettingsSectionContribution[]): number {
|
||||
const builtInOrder: Record<string, number> = {
|
||||
profile: 10,
|
||||
"mail-profiles": 20,
|
||||
"file-connectors": 30,
|
||||
interface: 10,
|
||||
workspace: 20,
|
||||
"local-connection": 30
|
||||
};
|
||||
return builtInOrder[sectionId] ?? contributedSections.find((section) => section.id === sectionId)?.order ?? 100;
|
||||
}
|
||||
|
||||
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
|
||||
@@ -457,3 +518,24 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
|
||||
function themeLabel(value: UserUiTheme): string {
|
||||
return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label;
|
||||
}
|
||||
|
||||
function ThemePreview({ theme }: {theme: UserUiTheme;}) {
|
||||
const variants: UserUiTheme[] = theme === "system" ? ["light", "dark"] : [theme];
|
||||
return (
|
||||
<div className="theme-preview-list" aria-label="i18n:govoplan-core.theme.a797e309">
|
||||
{variants.map((variant) =>
|
||||
<div key={variant} className="theme-preview" data-preview-theme={variant}>
|
||||
<div className="theme-preview-header">
|
||||
<span>{themeLabel(variant)}</span>
|
||||
<i />
|
||||
</div>
|
||||
<div className="theme-preview-body">
|
||||
<strong>GovOPlaN</strong>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user