chore: sync GovOPlaN module split state
This commit is contained in:
@@ -4,8 +4,10 @@ import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { fetchSystemSettings, updateSystemSettings, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsItem } from "../../api/admin";
|
||||
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
||||
import { fetchSystemSettingsDelta, updateSystemSettings, type LanguagePackage, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsDeltaSections, type SystemSettingsItem } from "../../api/admin";
|
||||
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
|
||||
const DELTA_KEY = "admin:system-settings";
|
||||
|
||||
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
|
||||
store_raw_campaign_json: true,
|
||||
@@ -35,86 +37,220 @@ const fallback: SystemSettingsItem = {
|
||||
allow_tenant_api_keys: true,
|
||||
privacy_retention_policy: defaultPrivacyPolicy,
|
||||
maintenance_mode: { enabled: false, message: "" },
|
||||
available_languages: [
|
||||
{ code: "en", label: "English", native_label: "English" },
|
||||
{ code: "de", label: "German", native_label: "Deutsch" }
|
||||
],
|
||||
enabled_language_codes: ["en", "de"],
|
||||
settings: {}
|
||||
};
|
||||
|
||||
export default function SystemSettingsPanel({ settings, canWrite, canAccessMaintenance }: { settings: ApiSettings; canWrite: boolean; canAccessMaintenance: boolean }) {
|
||||
export default function SystemSettingsPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) {
|
||||
const [draft, setDraft] = useState<SystemSettingsItem>(fallback);
|
||||
const [savedDraft, setSavedDraft] = useState<SystemSettingsItem>(fallback);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [packageDraft, setPackageDraft] = useState({ code: "", label: "", nativeLabel: "" });
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const defaultLocaleOptions = localeOptions(draft.default_locale, draft.enabled_language_codes);
|
||||
const dirty = systemSettingsDraftKey(draft) !== systemSettingsDraftKey(savedDraft);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: () => {
|
||||
setDraft(savedDraft);
|
||||
setPackageDraft({ code: "", label: "", nativeLabel: "" });
|
||||
}
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const loaded = await fetchSystemSettings(settings);
|
||||
setDraft(loaded);
|
||||
const wasDirty = systemSettingsDraftKey(draft) !== systemSettingsDraftKey(savedDraft);
|
||||
const response = await fetchSystemSettingsDelta(settings, { since: getDeltaWatermark(DELTA_KEY) });
|
||||
setDeltaWatermark(DELTA_KEY, response.watermark);
|
||||
if (response.full && response.item) {
|
||||
setSavedDraft(response.item);
|
||||
if (!wasDirty) setDraft(response.item);
|
||||
} else if (response.changed_sections.length) {
|
||||
setSavedDraft((current) => applySystemSettingsSections(current, response.sections));
|
||||
if (!wasDirty) setDraft((current) => applySystemSettingsSections(current, response.sections));
|
||||
}
|
||||
}
|
||||
catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setLoading(false);}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
useEffect(() => {
|
||||
resetDeltaWatermark(DELTA_KEY);
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||
|
||||
async function save() {
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDraft(await updateSystemSettings(settings, {
|
||||
const saved = await updateSystemSettings(settings, {
|
||||
default_locale: draft.default_locale,
|
||||
allow_tenant_custom_groups: draft.allow_tenant_custom_groups,
|
||||
allow_tenant_custom_roles: draft.allow_tenant_custom_roles,
|
||||
allow_tenant_api_keys: draft.allow_tenant_api_keys,
|
||||
maintenance_mode: draft.maintenance_mode
|
||||
}));
|
||||
setSuccess("System settings saved.");
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
maintenance_mode: draft.maintenance_mode,
|
||||
available_languages: draft.available_languages,
|
||||
enabled_language_codes: draft.enabled_language_codes
|
||||
});
|
||||
setDraft(saved);
|
||||
setSavedDraft(saved);
|
||||
resetDeltaWatermark(DELTA_KEY);
|
||||
setSuccess("i18n:govoplan-admin.system_settings_saved.dac4f17b");
|
||||
return true;
|
||||
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
function toggleLanguage(code: string, checked: boolean) {
|
||||
const enabled = new Set(draft.enabled_language_codes);
|
||||
if (checked) enabled.add(code);
|
||||
else enabled.delete(code);
|
||||
const nextEnabled = draft.available_languages.map((item) => item.code).filter((item) => enabled.has(item));
|
||||
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
|
||||
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
|
||||
}
|
||||
|
||||
function installLanguagePackage() {
|
||||
const code = normalizeLanguageCode(packageDraft.code);
|
||||
if (!code || !packageDraft.label.trim()) return;
|
||||
const nextPackage: LanguagePackage = {
|
||||
code,
|
||||
label: packageDraft.label.trim(),
|
||||
native_label: packageDraft.nativeLabel.trim() || packageDraft.label.trim()
|
||||
};
|
||||
const existing = new Set(draft.available_languages.map((item) => item.code));
|
||||
const available = existing.has(code)
|
||||
? draft.available_languages.map((item) => item.code === code ? nextPackage : item)
|
||||
: [...draft.available_languages, nextPackage];
|
||||
const enabled = draft.enabled_language_codes.includes(code) ? draft.enabled_language_codes : [...draft.enabled_language_codes, code];
|
||||
setDraft({ ...draft, available_languages: available, enabled_language_codes: enabled, default_locale: draft.default_locale || code });
|
||||
setPackageDraft({ code: "", label: "", nativeLabel: "" });
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title="System general settings"
|
||||
description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section."
|
||||
title="i18n:govoplan-admin.system_general_settings.7cd662ed"
|
||||
description="i18n:govoplan-admin.instance_wide_defaults_and_tenant_governance_cap.096a4f97"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy}>{busy ? "Saving…" : "Save settings"}</Button></>}
|
||||
>
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-admin.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy}>{busy ? "i18n:govoplan-admin.saving.56a2285c" : "i18n:govoplan-admin.save_settings.913aba9f"}</Button></>}>
|
||||
|
||||
<div className="admin-settings-form">
|
||||
<Card title="Defaults for newly created tenants">
|
||||
<FormField label="Default locale"><input value={draft.default_locale} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} /></FormField>
|
||||
<Card title="i18n:govoplan-admin.defaults_for_newly_created_tenants.9d0f4ccd">
|
||||
<FormField label="i18n:govoplan-admin.default_locale.b99d021f">
|
||||
<select value={draft.default_locale} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} disabled={!canWrite || busy || defaultLocaleOptions.length === 0}>
|
||||
{defaultLocaleOptions.map((code) => {
|
||||
const language = draft.available_languages.find((item) => item.code === code);
|
||||
return <option key={code} value={code}>{languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</option>;
|
||||
})}
|
||||
</select>
|
||||
</FormField>
|
||||
</Card>
|
||||
<Card title="Tenant administration capabilities">
|
||||
<Card title="i18n:govoplan-admin.language_packages">
|
||||
<div className="settings-list">
|
||||
<ToggleSwitch checked={draft.allow_tenant_custom_groups} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_groups: checked })} label="Allow tenant-defined groups by default" />
|
||||
<ToggleSwitch checked={draft.allow_tenant_custom_roles} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_roles: checked })} label="Allow tenant-defined roles by default" />
|
||||
<ToggleSwitch checked={draft.allow_tenant_api_keys} onChange={(checked) => setDraft({ ...draft, allow_tenant_api_keys: checked })} label="Allow tenant API keys by default" />
|
||||
{draft.available_languages.map((item) => (
|
||||
<label className="admin-inline-check" key={item.code}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled_language_codes.includes(item.code)}
|
||||
disabled={!canWrite || busy || item.code === draft.default_locale}
|
||||
onChange={(event) => toggleLanguage(item.code, event.target.checked)} />
|
||||
<span><strong>{item.code.toUpperCase()}</strong> {languageOptionLabel(item)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="muted small-note">These settings are enforced by the backend. Central groups and tenant roles remain available even when local creation is disabled.</p>
|
||||
<div className="form-grid">
|
||||
<FormField label="i18n:govoplan-admin.language_code">
|
||||
<input value={packageDraft.code} disabled={!canWrite || busy} placeholder="fr" onChange={(event) => setPackageDraft({ ...packageDraft, code: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-admin.language_name">
|
||||
<input value={packageDraft.label} disabled={!canWrite || busy} placeholder="i18n:govoplan-admin.language_name_placeholder" onChange={(event) => setPackageDraft({ ...packageDraft, label: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-admin.native_language_name">
|
||||
<input value={packageDraft.nativeLabel} disabled={!canWrite || busy} placeholder="i18n:govoplan-admin.native_language_name_placeholder" onChange={(event) => setPackageDraft({ ...packageDraft, nativeLabel: event.target.value })} />
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={installLanguagePackage} disabled={!canWrite || busy || !normalizeLanguageCode(packageDraft.code) || !packageDraft.label.trim()}>i18n:govoplan-admin.install_language_package</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted small-note">i18n:govoplan-admin.language_packages_help</p>
|
||||
</Card>
|
||||
<Card title="Maintenance mode">
|
||||
<Card title="i18n:govoplan-admin.tenant_administration_capabilities.5d265972">
|
||||
<div className="settings-list">
|
||||
<ToggleSwitch checked={draft.allow_tenant_custom_groups} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_groups: checked })} label="i18n:govoplan-admin.allow_tenant_defined_groups_by_default.32099d5a" />
|
||||
<ToggleSwitch checked={draft.allow_tenant_custom_roles} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_roles: checked })} label="i18n:govoplan-admin.allow_tenant_defined_roles_by_default.b1f79ee9" />
|
||||
<ToggleSwitch checked={draft.allow_tenant_api_keys} onChange={(checked) => setDraft({ ...draft, allow_tenant_api_keys: checked })} label="i18n:govoplan-admin.allow_tenant_api_keys_by_default.58a6cf17" />
|
||||
</div>
|
||||
<p className="muted small-note">i18n:govoplan-admin.these_settings_are_enforced_by_the_backend_centr.8112ed6f</p>
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-admin.maintenance_mode.98cca5c6">
|
||||
<div className="settings-list">
|
||||
<ToggleSwitch
|
||||
checked={draft.maintenance_mode.enabled}
|
||||
disabled={!canWrite || !canAccessMaintenance}
|
||||
onChange={(checked) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, enabled: checked } })}
|
||||
label="Restrict authenticated API access to maintenance operators"
|
||||
/>
|
||||
label="i18n:govoplan-admin.restrict_authenticated_api_access_to_maintenance.2bc47195" />
|
||||
|
||||
</div>
|
||||
<FormField label="Maintenance message">
|
||||
<FormField label="i18n:govoplan-admin.maintenance_message.ca62571f">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={draft.maintenance_mode.message ?? ""}
|
||||
disabled={!canWrite}
|
||||
onChange={(event) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, message: event.target.value } })}
|
||||
/>
|
||||
onChange={(event) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, message: event.target.value } })} />
|
||||
|
||||
</FormField>
|
||||
<p className="muted small-note">Changing the maintenance-mode flag requires system:maintenance:access. Login remains available so an operator can sign in during maintenance.</p>
|
||||
<p className="muted small-note">i18n:govoplan-admin.changing_the_maintenance_mode_flag_requires_syst.4492050f</p>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
function normalizeLanguageCode(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/_/g, "-").split(/[^a-z0-9]+/).filter(Boolean).join("-");
|
||||
}
|
||||
|
||||
function localeOptions(current: string, enabled: string[]): string[] {
|
||||
return [...new Set([current, ...enabled].filter((item) => item && item.trim()))];
|
||||
}
|
||||
|
||||
function languageOptionLabel(language: LanguagePackage): string {
|
||||
return `${language.code.toUpperCase()} - ${language.native_label || language.label}`;
|
||||
}
|
||||
|
||||
function systemSettingsDraftKey(item: SystemSettingsItem): string {
|
||||
return JSON.stringify({
|
||||
default_locale: item.default_locale,
|
||||
allow_tenant_custom_groups: item.allow_tenant_custom_groups,
|
||||
allow_tenant_custom_roles: item.allow_tenant_custom_roles,
|
||||
allow_tenant_api_keys: item.allow_tenant_api_keys,
|
||||
maintenance_mode: item.maintenance_mode,
|
||||
available_languages: item.available_languages,
|
||||
enabled_language_codes: item.enabled_language_codes
|
||||
});
|
||||
}
|
||||
|
||||
function applySystemSettingsSections(item: SystemSettingsItem, sections: SystemSettingsDeltaSections): SystemSettingsItem {
|
||||
return {
|
||||
...item,
|
||||
...(sections.defaults ?? {}),
|
||||
...(sections.tenant_capabilities ?? {}),
|
||||
...(sections.languages ?? {}),
|
||||
...(sections.privacy_retention_policy ? { privacy_retention_policy: sections.privacy_retention_policy } : {}),
|
||||
...(sections.maintenance_mode ? { maintenance_mode: sections.maintenance_mode } : {}),
|
||||
...(sections.settings ? { settings: sections.settings } : {})
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user