diff --git a/src/govoplan_admin/backend/api/v1/routes.py b/src/govoplan_admin/backend/api/v1/routes.py index d47ffd7..8513d93 100644 --- a/src/govoplan_admin/backend/api/v1/routes.py +++ b/src/govoplan_admin/backend/api/v1/routes.py @@ -15,6 +15,7 @@ from govoplan_core.audit.logging import audit_from_principal, audit_operation_co from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment from govoplan_core.admin.common import AdminConflictError, AdminValidationError from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration +from govoplan_core.core.appearance import appearance_settings, update_appearance_settings from govoplan_core.core.change_sequence import ( decode_sequence_watermark, encode_sequence_watermark, @@ -159,6 +160,7 @@ SYSTEM_SETTINGS_SECTIONS = ( "privacy_retention_policy", "maintenance_mode", "navigation", + "appearance", "settings", ) @@ -295,6 +297,7 @@ def _system_settings_item(session: Session) -> SystemSettingsItem: maintenance_mode = saved_maintenance_mode(session) i18n_payload = system_i18n_payload(item) navigation = navigation_preferences_from_settings(item.settings) + appearance_palette, appearance_palette_locked = appearance_settings(item.settings) return SystemSettingsItem( default_locale=item.default_locale, allow_tenant_custom_groups=item.allow_tenant_custom_groups, @@ -306,6 +309,8 @@ def _system_settings_item(session: Session) -> SystemSettingsItem: enabled_language_codes=i18n_payload["enabled_languages"], settings=item.settings or {}, navigation=navigation.as_dict() if navigation is not None else None, + appearance_palette=appearance_palette or "default", + appearance_palette_locked=appearance_palette_locked, ) @@ -325,6 +330,10 @@ def _system_settings_sections(item: SystemSettingsItem) -> dict[str, Any]: "privacy_retention_policy": payload["privacy_retention_policy"], "maintenance_mode": payload["maintenance_mode"], "navigation": payload["navigation"], + "appearance": { + "appearance_palette": payload["appearance_palette"], + "appearance_palette_locked": payload["appearance_palette_locked"], + }, "settings": payload["settings"], } @@ -1783,6 +1792,23 @@ def write_system_settings( before_sections = _system_settings_sections(_system_settings_item(session)) before_privacy = privacy_policy_from_settings(item).model_dump(mode="json") before_maintenance = saved_maintenance_mode(session).as_dict() + before_appearance_palette, before_appearance_locked = appearance_settings(item.settings) + appearance_palette_changed = ( + "appearance_palette" in payload.model_fields_set + and payload.appearance_palette is not None + and payload.appearance_palette != (before_appearance_palette or "default") + ) + if ( + ( + "appearance_palette_locked" in payload.model_fields_set + and payload.appearance_palette_locked != before_appearance_locked + ) + or (before_appearance_locked and appearance_palette_changed) + ) and not has_scope(principal, "admin:policies:write"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Changing the system appearance lock or its locked value requires admin:policies:write.", + ) if payload.privacy_retention_policy is not None: privacy_value = payload.privacy_retention_policy.model_dump(mode="json") try: @@ -1819,6 +1845,13 @@ def write_system_settings( available_languages=available_languages, enabled_language_codes=enabled_language_codes, ) + if {"appearance_palette", "appearance_palette_locked"}.intersection(payload.model_fields_set): + current_palette, current_locked = appearance_settings(item.settings) + item.settings = update_appearance_settings( + item.settings, + default_palette=payload.appearance_palette or current_palette or "default", + palette_locked=payload.appearance_palette_locked if payload.appearance_palette_locked is not None else current_locked, + ) if "navigation" in payload.model_fields_set: item.settings = update_navigation_preferences( item.settings, diff --git a/src/govoplan_admin/backend/api/v1/schemas.py b/src/govoplan_admin/backend/api/v1/schemas.py index 1e8a526..6143211 100644 --- a/src/govoplan_admin/backend/api/v1/schemas.py +++ b/src/govoplan_admin/backend/api/v1/schemas.py @@ -51,6 +51,8 @@ class SystemSettingsItem(BaseModel): enabled_language_codes: list[str] = Field(default_factory=list) settings: dict[str, Any] = Field(default_factory=dict) navigation: NavigationPreferencesPayload | None = None + appearance_palette: Literal["default", "civic_blue", "forest", "plum"] = "default" + appearance_palette_locked: bool = False class SystemSettingsDeltaResponse(BaseModel): @@ -75,6 +77,8 @@ class SystemSettingsUpdateRequest(BaseModel): available_languages: list[LanguagePackageItem] | None = None enabled_language_codes: list[str] | None = None navigation: NavigationPreferencesPayload | None = None + appearance_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None + appearance_palette_locked: bool | None = None change_request_id: str | None = None diff --git a/src/govoplan_admin/backend/manifest.py b/src/govoplan_admin/backend/manifest.py index c9d0dc5..9dea12e 100644 --- a/src/govoplan_admin/backend/manifest.py +++ b/src/govoplan_admin/backend/manifest.py @@ -75,7 +75,7 @@ manifest = ModuleManifest( id="admin.workspace", title="Use the administration workspace", summary="The administration workspace shows only the sections supplied by enabled modules and allowed by the current account's permissions.", - body="System and tenant administration share one workspace. Available sections can include settings, configuration changes and packages, governance templates, groups, and module lifecycle controls. A missing section normally means that its owning module is disabled or the current account lacks the required authority.", + body="System and tenant administration share one workspace. Available sections can include settings, configuration changes and packages, governance templates, groups, and module lifecycle controls. A missing section normally means that its owning module is disabled or the current account lacks the required authority. System appearance settings select a validated palette default. Changing the separate palette lock additionally requires policy-write authority; a system lock suppresses tenant and personal palette choices, while an unlocked default remains inheritable and overridable.", documentation_types=("admin", "user"), audience=("tenant_admin", "system_admin", "operator"), metadata={ diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index 48624db..e9d67b9 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -1,4 +1,4 @@ -import type { ApiSettings, DeltaDeletedItem, NavigationPreferences, PrivacyRetentionPolicy } from "@govoplan/core-webui"; +import type { ApiSettings, DeltaDeletedItem, NavigationPreferences, PrivacyRetentionPolicy, UserUiPalette } from "@govoplan/core-webui"; import { apiDownload, apiFetch, apiPath, apiQuery } from "@govoplan/core-webui"; export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui"; export type { @@ -34,6 +34,8 @@ export type SystemSettingsItem = { enabled_language_codes: string[]; settings: Record; navigation: NavigationPreferences | null; + appearance_palette: UserUiPalette; + appearance_palette_locked: boolean; }; export type SystemSettingsDeltaSections = Partial<{ @@ -44,6 +46,7 @@ export type SystemSettingsDeltaSections = Partial<{ maintenance_mode: SystemSettingsItem["maintenance_mode"]; settings: SystemSettingsItem["settings"]; navigation: SystemSettingsItem["navigation"]; + appearance: Pick; }>; export type SystemSettingsUpdatePayload = { @@ -56,6 +59,8 @@ export type SystemSettingsUpdatePayload = { available_languages?: LanguagePackage[] | null; enabled_language_codes?: string[] | null; navigation?: NavigationPreferences | null; + appearance_palette?: UserUiPalette; + appearance_palette_locked?: boolean; change_request_id?: string | null; }; diff --git a/webui/src/features/admin/SystemSettingsPanel.tsx b/webui/src/features/admin/SystemSettingsPanel.tsx index d2a960a..b993146 100644 --- a/webui/src/features/admin/SystemSettingsPanel.tsx +++ b/webui/src/features/admin/SystemSettingsPanel.tsx @@ -4,6 +4,7 @@ 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 { AppearancePalettePreview, AppearancePaletteSelect } from "@govoplan/core-webui"; import { fetchSystemSettingsDelta, updateSystemSettings, type SystemSettingsItem } from "../../api/admin"; import { AdminPageLayout, DocumentationHelpLink, NavigationPreferenceEditor, adminErrorMessage, configurableNavigationItemsForModules, dispatchPlatformModulesChanged, useDeltaWatermarks, usePlatformModules, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { ADMIN_GOVERNANCE_DOCUMENTATION, ADMIN_INTERFACE_I18N, mutationDisabledReason } from "./interfacePatterns"; @@ -11,7 +12,7 @@ import { SYSTEM_SETTINGS_FALLBACK, applySystemSettingsSections } from "./systemS const DELTA_KEY = "admin:system-settings"; -export default function SystemSettingsPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) { +export default function SystemSettingsPanel({ settings, canWrite, canAccessMaintenance, canWritePolicy }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;canWritePolicy: boolean;}) { const modules = usePlatformModules(); const navigationItems = configurableNavigationItemsForModules(modules); const [draft, setDraft] = useState(SYSTEM_SETTINGS_FALLBACK); @@ -63,7 +64,9 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint allow_tenant_custom_roles: draft.allow_tenant_custom_roles, allow_tenant_api_keys: draft.allow_tenant_api_keys, maintenance_mode: draft.maintenance_mode, - navigation: draft.navigation + navigation: draft.navigation, + appearance_palette: draft.appearance_palette, + appearance_palette_locked: draft.appearance_palette_locked }); setDraft(saved); setSavedDraft(saved); @@ -102,6 +105,24 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint disabled={!canWrite || busy} /> + + + setDraft({ ...draft, appearance_palette: appearance_palette ?? "default" })} + disabled={!canWrite || busy || (draft.appearance_palette_locked && !canWritePolicy)} + /> + + setDraft({ ...draft, appearance_palette_locked })} + disabled={!canWrite || !canWritePolicy || busy} + help={!canWritePolicy ? "i18n:govoplan-admin.appearance_lock_policy_permission" : undefined} + label="i18n:govoplan-admin.lock_system_palette" + /> + +

i18n:govoplan-admin.system_palette_precedence_help

+
createElement(SystemSettingsPanel, { settings, canWrite: hasScope(auth, "system:settings:write"), - canAccessMaintenance: hasScope(auth, "system:maintenance:access") + canAccessMaintenance: hasScope(auth, "system:maintenance:access"), + canWritePolicy: hasScope(auth, "admin:policies:write") }) }, {