diff --git a/src/govoplan_tenancy/backend/api/v1/routes.py b/src/govoplan_tenancy/backend/api/v1/routes.py index 9d9c921..796cc1e 100644 --- a/src/govoplan_tenancy/backend/api/v1/routes.py +++ b/src/govoplan_tenancy/backend/api/v1/routes.py @@ -18,7 +18,14 @@ from govoplan_core.core.access import ( TenantContextSwitcher, ) from govoplan_core.core.change_sequence import ChangeSequenceEntry, decode_sequence_watermark, encode_sequence_watermark, record_change, sequence_watermark_is_expired -from govoplan_core.core.appearance import APPEARANCE_SETTINGS_KEY, appearance_settings, resolve_effective_appearance, update_appearance_settings +from govoplan_core.core.appearance import ( + APPEARANCE_SETTINGS_KEY, + appearance_custom_overrides_policy, + appearance_settings, + resolve_effective_appearance, + update_appearance_custom_overrides_policy, + update_appearance_settings, +) from govoplan_core.core.module_entitlements import MODULE_ENTITLEMENTS_KEY from govoplan_core.core.navigation import ( navigation_preferences_from_settings, @@ -259,6 +266,8 @@ def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsIte navigation = navigation_preferences_from_settings(tenant.settings) system_palette, system_locked = appearance_settings(system_settings.settings) tenant_palette, tenant_locked = appearance_settings(tenant.settings) + system_custom_overrides_allowed = appearance_custom_overrides_policy(system_settings.settings) is True + tenant_custom_overrides_allowed = appearance_custom_overrides_policy(tenant.settings) effective_appearance = resolve_effective_appearance( system_settings=system_settings.settings, tenant_settings=tenant.settings, @@ -279,6 +288,9 @@ def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsIte system_appearance_palette_locked=system_locked, effective_appearance_palette=effective_appearance.palette, effective_appearance_source=effective_appearance.source, + appearance_custom_overrides_allowed=tenant_custom_overrides_allowed, + system_appearance_custom_overrides_allowed=system_custom_overrides_allowed, + effective_appearance_custom_overrides_allowed=effective_appearance.custom_overrides_allowed, settings=tenant.settings or {}, ) @@ -301,6 +313,9 @@ def _tenant_settings_sections(item: TenantSettingsItem) -> dict[str, Any]: "system_appearance_palette_locked": payload["system_appearance_palette_locked"], "effective_appearance_palette": payload["effective_appearance_palette"], "effective_appearance_source": payload["effective_appearance_source"], + "appearance_custom_overrides_allowed": payload["appearance_custom_overrides_allowed"], + "system_appearance_custom_overrides_allowed": payload["system_appearance_custom_overrides_allowed"], + "effective_appearance_custom_overrides_allowed": payload["effective_appearance_custom_overrides_allowed"], }, "settings": payload["settings"], } @@ -997,6 +1012,8 @@ def update_tenant_settings( system_settings = get_system_settings(session) tenant_palette, tenant_locked = appearance_settings(tenant.settings) system_palette, system_locked = appearance_settings(system_settings.settings) + system_custom_overrides_allowed = appearance_custom_overrides_policy(system_settings.settings) is True + tenant_custom_overrides_allowed = appearance_custom_overrides_policy(tenant.settings) appearance_palette_changed = ( "appearance_palette" in payload.model_fields_set and payload.appearance_palette != tenant_palette @@ -1005,17 +1022,26 @@ def update_tenant_settings( "appearance_palette_locked" in payload.model_fields_set and payload.appearance_palette_locked != tenant_locked ) + custom_overrides_policy_changed = ( + "appearance_custom_overrides_allowed" in payload.model_fields_set + and payload.appearance_custom_overrides_allowed != tenant_custom_overrides_allowed + ) + if payload.appearance_custom_overrides_allowed is True and not system_custom_overrides_allowed: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="The system appearance policy does not allow personal custom overrides.", + ) if system_locked and (appearance_palette_changed or appearance_lock_changed): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"The system appearance policy locks palette {system_palette or 'default'}.", ) if ( - appearance_lock_changed or (tenant_locked and appearance_palette_changed) + appearance_lock_changed or (tenant_locked and appearance_palette_changed) or custom_overrides_policy_changed ) and not has_scope(principal, "admin:policies:write"): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail="Changing the tenant appearance lock or its locked value requires admin:policies:write.", + detail="Changing the tenant appearance policy requires admin:policies:write.", ) system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale) current_i18n = i18n_settings(tenant.settings) @@ -1035,6 +1061,11 @@ def update_tenant_settings( default_palette=payload.appearance_palette if "appearance_palette" in payload.model_fields_set else current_palette, palette_locked=payload.appearance_palette_locked if payload.appearance_palette_locked is not None else current_locked, ) + if "appearance_custom_overrides_allowed" in payload.model_fields_set: + tenant.settings = update_appearance_custom_overrides_policy( + tenant.settings, + allowed=payload.appearance_custom_overrides_allowed, + ) if "navigation" in payload.model_fields_set: tenant.settings = update_navigation_preferences( tenant.settings, @@ -1052,9 +1083,14 @@ def update_tenant_settings( "default_locale": tenant.default_locale, "enabled_language_codes": enabled, "navigation_updated": "navigation" in payload.model_fields_set, - "appearance_updated": bool({"appearance_palette", "appearance_palette_locked"}.intersection(payload.model_fields_set)), + "appearance_updated": bool( + {"appearance_palette", "appearance_palette_locked", "appearance_custom_overrides_allowed"}.intersection( + payload.model_fields_set + ) + ), "appearance_palette": appearance_settings(tenant.settings)[0], "appearance_palette_locked": appearance_settings(tenant.settings)[1], + "appearance_custom_overrides_allowed": appearance_custom_overrides_policy(tenant.settings), }, ) after_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant)) diff --git a/src/govoplan_tenancy/backend/api/v1/schemas.py b/src/govoplan_tenancy/backend/api/v1/schemas.py index 4576167..a915121 100644 --- a/src/govoplan_tenancy/backend/api/v1/schemas.py +++ b/src/govoplan_tenancy/backend/api/v1/schemas.py @@ -144,6 +144,9 @@ class TenantSettingsItem(BaseModel): system_appearance_palette_locked: bool = False effective_appearance_palette: Literal["default", "civic_blue", "forest", "plum"] = "default" effective_appearance_source: Literal["tenant", "system", "tenant_lock", "system_lock"] = "system" + appearance_custom_overrides_allowed: bool | None = None + system_appearance_custom_overrides_allowed: bool = False + effective_appearance_custom_overrides_allowed: bool = False settings: dict[str, Any] = Field(default_factory=dict) @@ -165,3 +168,4 @@ class TenantSettingsUpdateRequest(BaseModel): navigation: NavigationPreferencesPayload | None = None appearance_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None appearance_palette_locked: bool | None = None + appearance_custom_overrides_allowed: bool | None = None diff --git a/src/govoplan_tenancy/backend/manifest.py b/src/govoplan_tenancy/backend/manifest.py index 7a8b620..ddeff13 100644 --- a/src/govoplan_tenancy/backend/manifest.py +++ b/src/govoplan_tenancy/backend/manifest.py @@ -60,7 +60,7 @@ manifest = ModuleManifest( id="tenancy.lifecycle-and-settings", title="Administer tenant lifecycle and settings", summary="Tenancy adds explicit tenant creation, activation, context resolution, and tenant-owned settings over Core's shared scope storage.", - body="A tenant is a concrete administrative and data boundary. Tenant lifecycle changes must preserve ownership and recovery guarantees for module-owned records. New tenants default to the German reference language unless the administrator selects another enabled system language; existing tenant and user preferences remain unchanged. Tenant administrators can inherit or override the system side-rail order and visibility and can lock entries visible for users; system locks remain effective. Personal navigation preferences still take precedence except that they cannot hide locked entries. Tenant appearance likewise inherits the system palette until explicitly selected; an unlocked tenant default permits a personal palette, while a policy-authorized tenant lock suppresses it and a system lock always wins. Resetting the tenant palette restores inheritance rather than copying the current system value. Navigation and appearance changes never grant module entitlement, View visibility, or permissions. Tenancy contributes system tenant management and tenant settings to the shared administration workspace; without this module, the Core and Access baseline can operate in single-scope compatibility mode. Core-reserved module entitlement settings are managed only through the Admin module's tenant-module policy endpoints and are preserved when generic tenant settings are replaced.", + body="A tenant is a concrete administrative and data boundary. Tenant lifecycle changes must preserve ownership and recovery guarantees for module-owned records. New tenants default to the German reference language unless the administrator selects another enabled system language; existing tenant and user preferences remain unchanged. Tenant administrators can inherit or override the system side-rail order and visibility and can lock entries visible for users; system locks remain effective. Personal navigation preferences still take precedence except that they cannot hide locked entries. Tenant appearance likewise inherits the system palette until explicitly selected; an unlocked tenant default permits a personal palette, while a policy-authorized tenant lock suppresses it and a system lock always wins. Resetting the tenant palette restores inheritance rather than copying the current system value. When the system permits advanced personal color overrides, a policy-authorized tenant administrator may inherit, allow, or block them; the tenant cannot enable a system-denied policy, and palette locks still suppress the editor. Advanced documents cover both light and dark modes and are validated atomically by Core. Navigation and appearance changes never grant module entitlement, View visibility, or permissions. Tenancy contributes system tenant management and tenant settings to the shared administration workspace; without this module, the Core and Access baseline can operate in single-scope compatibility mode. Core-reserved module entitlement settings are managed only through the Admin module's tenant-module policy endpoints and are preserved when generic tenant settings are replaced.", documentation_types=("admin",), audience=("system_admin", "tenant_admin", "operator"), related_modules=("access", "admin", "audit"), diff --git a/webui/src/api/tenancy.ts b/webui/src/api/tenancy.ts index 8762725..255537e 100644 --- a/webui/src/api/tenancy.ts +++ b/webui/src/api/tenancy.ts @@ -50,6 +50,9 @@ export type TenantSettingsItem = { system_appearance_palette_locked: boolean; effective_appearance_palette: UserUiPalette; effective_appearance_source: "tenant" | "system" | "tenant_lock" | "system_lock"; + appearance_custom_overrides_allowed: boolean | null; + system_appearance_custom_overrides_allowed: boolean; + effective_appearance_custom_overrides_allowed: boolean; settings: Record; }; @@ -61,7 +64,7 @@ export type TenantSettingsDeltaSections = Partial<{ "available_languages" | "system_enabled_language_codes" | "enabled_language_codes" >; navigation: TenantSettingsItem["navigation"]; - appearance: Pick; + appearance: Pick; settings: TenantSettingsItem["settings"]; }>; @@ -161,6 +164,7 @@ export function updateTenantSettings( navigation?: NavigationPreferences | null; appearance_palette?: UserUiPalette | null; appearance_palette_locked?: boolean; + appearance_custom_overrides_allowed?: boolean | null; } ): Promise { return apiFetch(settings, "/api/v1/admin/tenant/settings", { diff --git a/webui/src/features/admin/TenantSettingsPanel.tsx b/webui/src/features/admin/TenantSettingsPanel.tsx index 8999020..fabe10b 100644 --- a/webui/src/features/admin/TenantSettingsPanel.tsx +++ b/webui/src/features/admin/TenantSettingsPanel.tsx @@ -51,7 +51,10 @@ const fallback: TenantSettingsItem = { system_appearance_palette: "default", system_appearance_palette_locked: false, effective_appearance_palette: "default", - effective_appearance_source: "system" + effective_appearance_source: "system", + appearance_custom_overrides_allowed: null, + system_appearance_custom_overrides_allowed: false, + effective_appearance_custom_overrides_allowed: false }; export default function TenantSettingsPanel({ @@ -76,6 +79,11 @@ export default function TenantSettingsPanel({ const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const defaultLocaleOptions = localeOptions(draft.default_locale, draft.enabled_language_codes); const dirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft); + const customOverridesEffectivelyAllowed = + draft.system_appearance_custom_overrides_allowed + && draft.appearance_custom_overrides_allowed !== false + && !draft.system_appearance_palette_locked + && !draft.appearance_palette_locked; const saveDisabledReason = tenantMutationDisabledReason({ busy, permitted: canWrite, @@ -127,7 +135,8 @@ export default function TenantSettingsPanel({ enabled_language_codes: draft.enabled_language_codes, navigation: draft.navigation, appearance_palette: draft.appearance_palette, - appearance_palette_locked: draft.appearance_palette_locked + appearance_palette_locked: draft.appearance_palette_locked, + appearance_custom_overrides_allowed: draft.appearance_custom_overrides_allowed }); setDraft(saved); setSavedDraft(saved); @@ -230,10 +239,36 @@ export default function TenantSettingsPanel({ help={!canWritePolicy ? "i18n:govoplan-tenancy.appearance_lock_policy_permission" : draft.system_appearance_palette_locked ? "i18n:govoplan-tenancy.system_palette_is_locked" : undefined} label="i18n:govoplan-tenancy.lock_tenant_palette" /> + + +
i18n:govoplan-tenancy.effective_source
{draft.system_appearance_palette_locked ? "i18n:govoplan-tenancy.system_lock" : draft.appearance_palette ? "i18n:govoplan-tenancy.tenant_default" : "i18n:govoplan-tenancy.system_default"}
i18n:govoplan-tenancy.user_override
{draft.system_appearance_palette_locked || draft.appearance_palette_locked ? "i18n:govoplan-tenancy.blocked_by_policy" : "i18n:govoplan-tenancy.allowed"}
+
i18n:govoplan-tenancy.advanced_color_overrides
{customOverridesEffectivelyAllowed ? "i18n:govoplan-tenancy.allowed" : "i18n:govoplan-tenancy.blocked_by_policy"}
@@ -255,7 +290,8 @@ function tenantSettingsDraftKey(item: TenantSettingsItem): string { enabled_language_codes: item.enabled_language_codes, navigation: item.navigation, appearance_palette: item.appearance_palette, - appearance_palette_locked: item.appearance_palette_locked + appearance_palette_locked: item.appearance_palette_locked, + appearance_custom_overrides_allowed: item.appearance_custom_overrides_allowed }); } diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 644ce01..39b9f03 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -6,6 +6,13 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-tenancy.tenant_palette_default": "Tenant palette default", "i18n:govoplan-tenancy.tenant_palette_default_help": "Inherit the system palette or select the default for this tenant.", "i18n:govoplan-tenancy.appearance_lock_policy_permission": "Policy-write permission is required to change this lock.", + "i18n:govoplan-tenancy.custom_overrides_policy": "Personal color override policy", + "i18n:govoplan-tenancy.custom_overrides_policy_help": "Inherit the system decision, or explicitly allow or block the validated advanced editor for this tenant.", + "i18n:govoplan-tenancy.appearance_policy_permission": "Policy-write permission is required to change this appearance policy.", + "i18n:govoplan-tenancy.inherit_system_policy": "Inherit system policy", + "i18n:govoplan-tenancy.allow_for_tenant_users": "Allow for tenant users", + "i18n:govoplan-tenancy.block_for_tenant_users": "Block for tenant users", + "i18n:govoplan-tenancy.advanced_color_overrides": "Advanced color overrides", "i18n:govoplan-tenancy.system_palette_is_locked": "The system palette is locked and takes precedence.", "i18n:govoplan-tenancy.lock_tenant_palette": "Lock the tenant palette", "i18n:govoplan-tenancy.effective_source": "Effective source", @@ -118,6 +125,13 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-tenancy.tenant_palette_default": "Mandantenstandard für die Farbpalette", "i18n:govoplan-tenancy.tenant_palette_default_help": "Systempalette übernehmen oder einen Standard für diesen Mandanten auswählen.", "i18n:govoplan-tenancy.appearance_lock_policy_permission": "Zum Ändern dieser Sperre ist das Recht zum Bearbeiten von Richtlinien erforderlich.", + "i18n:govoplan-tenancy.custom_overrides_policy": "Richtlinie für persönliche Farbanpassungen", + "i18n:govoplan-tenancy.custom_overrides_policy_help": "Die Systementscheidung übernehmen oder den geprüften erweiterten Editor für diesen Mandanten ausdrücklich zulassen oder sperren.", + "i18n:govoplan-tenancy.appearance_policy_permission": "Zum Ändern dieser Darstellungsrichtlinie ist das Recht zum Bearbeiten von Richtlinien erforderlich.", + "i18n:govoplan-tenancy.inherit_system_policy": "Systemrichtlinie übernehmen", + "i18n:govoplan-tenancy.allow_for_tenant_users": "Für Mandantenbenutzer zulassen", + "i18n:govoplan-tenancy.block_for_tenant_users": "Für Mandantenbenutzer sperren", + "i18n:govoplan-tenancy.advanced_color_overrides": "Erweiterte Farbanpassungen", "i18n:govoplan-tenancy.system_palette_is_locked": "Die Systempalette ist verbindlich und hat Vorrang.", "i18n:govoplan-tenancy.lock_tenant_palette": "Mandantenpalette verbindlich festlegen", "i18n:govoplan-tenancy.effective_source": "Wirksame Quelle",