feat: govern tenant appearance overrides
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -61,7 +64,7 @@ export type TenantSettingsDeltaSections = Partial<{
|
||||
"available_languages" | "system_enabled_language_codes" | "enabled_language_codes"
|
||||
>;
|
||||
navigation: TenantSettingsItem["navigation"];
|
||||
appearance: Pick<TenantSettingsItem, "appearance_palette" | "appearance_palette_locked" | "system_appearance_palette" | "system_appearance_palette_locked" | "effective_appearance_palette" | "effective_appearance_source">;
|
||||
appearance: Pick<TenantSettingsItem, "appearance_palette" | "appearance_palette_locked" | "system_appearance_palette" | "system_appearance_palette_locked" | "effective_appearance_palette" | "effective_appearance_source" | "appearance_custom_overrides_allowed" | "system_appearance_custom_overrides_allowed" | "effective_appearance_custom_overrides_allowed">;
|
||||
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<TenantSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/settings", {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<FormField
|
||||
label="i18n:govoplan-tenancy.custom_overrides_policy"
|
||||
help={!canWritePolicy ? "i18n:govoplan-tenancy.appearance_policy_permission" : "i18n:govoplan-tenancy.custom_overrides_policy_help"}
|
||||
>
|
||||
<select
|
||||
value={draft.appearance_custom_overrides_allowed === null ? "inherit" : draft.appearance_custom_overrides_allowed ? "allow" : "block"}
|
||||
disabled={!canWrite || !canWritePolicy || busy}
|
||||
onChange={(event) => {
|
||||
const appearance_custom_overrides_allowed = event.target.value === "inherit" ? null : event.target.value === "allow";
|
||||
setDraft({
|
||||
...draft,
|
||||
appearance_custom_overrides_allowed,
|
||||
effective_appearance_custom_overrides_allowed:
|
||||
draft.system_appearance_custom_overrides_allowed
|
||||
&& appearance_custom_overrides_allowed !== false
|
||||
&& !draft.system_appearance_palette_locked
|
||||
&& !draft.appearance_palette_locked
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="inherit">i18n:govoplan-tenancy.inherit_system_policy</option>
|
||||
<option value="allow" disabled={!draft.system_appearance_custom_overrides_allowed}>i18n:govoplan-tenancy.allow_for_tenant_users</option>
|
||||
<option value="block">i18n:govoplan-tenancy.block_for_tenant_users</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<AppearancePalettePreview palette={draft.system_appearance_palette_locked ? draft.system_appearance_palette : draft.appearance_palette ?? draft.system_appearance_palette} />
|
||||
<DescriptionList variant="inline">
|
||||
<div><dt>i18n:govoplan-tenancy.effective_source</dt><dd>{draft.system_appearance_palette_locked ? "i18n:govoplan-tenancy.system_lock" : draft.appearance_palette ? "i18n:govoplan-tenancy.tenant_default" : "i18n:govoplan-tenancy.system_default"}</dd></div>
|
||||
<div><dt>i18n:govoplan-tenancy.user_override</dt><dd>{draft.system_appearance_palette_locked || draft.appearance_palette_locked ? "i18n:govoplan-tenancy.blocked_by_policy" : "i18n:govoplan-tenancy.allowed"}</dd></div>
|
||||
<div><dt>i18n:govoplan-tenancy.advanced_color_overrides</dt><dd>{customOverridesEffectivelyAllowed ? "i18n:govoplan-tenancy.allowed" : "i18n:govoplan-tenancy.blocked_by_policy"}</dd></div>
|
||||
</DescriptionList>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user