16 Commits
Author SHA1 Message Date
zemion c067be36c1 fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:53 +02:00
zemion 58ebf2c7e6 Release govoplan-organizations v0.1.21: unify interface contracts and documentation
Module Package Release / publish-packages (push) Successful in 12s
2026-09-08 01:32:48 +02:00
zemion b23e6e4eee fix(webui): bind organization retention to help
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 11:36:41 +02:00
zemion 4408a9486f docs(organizations): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 13s
2026-08-23 19:31:24 +02:00
zemion 9f17de7a3f fix(i18n): complete organization model translations 2026-08-21 17:36:17 +02:00
zemion aa4ed0b7c1 feat(organizations): add governed DSAR coverage 2026-08-21 01:26:05 +02:00
zemion d1e3b1cbfd docs: define function delegation modes 2026-08-19 22:16:18 +02:00
zemion f34a6b17a9 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion 8b67f28ace Adopt semantic organization action layout 2026-08-19 14:26:26 +02:00
zemion bc0eb60064 feat: classify organizations product area 2026-08-18 21:32:50 +02:00
zemion 5bae5bbc3e Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion 1a7b9fbf16 Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion 8a3724bf8b Adopt shared WebUI layout primitives 2026-08-18 10:42:53 +02:00
zemion 30fcdbe832 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:45 +02:00
zemion 3ebe8c2c99 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 20:34:07 +02:00
zemion 18e4df348a Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:12 +02:00
16 changed files with 1048 additions and 127 deletions
+10 -2
View File
@@ -46,13 +46,21 @@ accessibility evidence are recorded in
## Module Contract
The module registers two capabilities from
`govoplan_core.core.organizations`:
The module registers organization-directory capabilities from
`govoplan_core.core.organizations` and a privacy capability:
- `organizations.directory` for backward-compatible direct unit/function
lookup;
- `organizations.hierarchyDirectory` for typed, tenant-safe, explicitly
structure-scoped hierarchy and path resolution.
- `privacy.dsar.organizations` for tenant-scoped account attribution on model
instantiations and upgrades.
The DSAR provider does not treat institutional units or functions as personal
records. It retains model-change attribution as governance evidence and excludes
global templates, opaque definitions, previews, decisions, provenance,
idempotency material, and other tenants. Identity-to-function assignments are
covered by IDM, which owns that relationship.
Feature modules should consume the capability instead of importing
organization ORM models.
+7 -4
View File
@@ -38,11 +38,14 @@ units, relations, functions, settings, and their mutation consequences.
## State And Accessibility Evidence
The module uses Core page, subnavigation, grid, tree, card, dialog, loading,
alert, status, blocker, field-help, and disabled-action controls. Stable table
The module uses Core `WorkspaceLayout` and `PageLayout` for both the standalone
workspace and embedded administration contribution, plus shared subnavigation,
grid, tree, card, dialog, loading, alert, status, blocker, field-help, and
disabled-action controls. Pane width, content inset, headings, route actions,
notices, scrolling, and responsive navigation are therefore Core-owned rather
than repeated in Organizations CSS. Stable table
action slots remain keyboard reachable; dialogs retain shared focus containment
and return. Responsive behavior stays owned by the existing workspace and admin
shell CSS. Labels and accessible properties use the English/German module
and return. Labels and accessible properties use the English/German module
catalogue, and API errors are bounded to the current tenant operation.
The manifest contributes stable help contexts for the workspace, sections,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/organizations-webui",
"version": "0.1.15",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.45",
"@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
+3 -3
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-organizations"
version = "0.1.15"
version = "0.1.21"
description = "GovOPlaN organizational model module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.15",
"govoplan-tenancy>=0.1.15",
"govoplan-core>=0.1.45",
"govoplan-tenancy>=0.1.18",
]
[tool.setuptools.packages.find]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN organizations module."""
__version__ = "0.1.15"
__version__ = "0.1.21"
@@ -0,0 +1,306 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_organizations.backend.db.models import (
OrganizationModelInstantiation,
OrganizationModelUpgrade,
)
ORGANIZATIONS_DSAR_CAPABILITY = dsar_capability_name("organizations")
_MAX_RECORDS = 5_000
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
account_id: str | None
instantiation_id: str | None
upgrade_id: str | None
class OrganizationsDsarProvider:
provider_id = "organizations"
module_id = "organizations"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None or not selectors.account_id:
return ()
records: list[DsarRecordRef] = []
instantiation_query = db.query(OrganizationModelInstantiation).filter(
OrganizationModelInstantiation.tenant_id == tenant_id,
OrganizationModelInstantiation.instantiated_by_account_id
== selectors.account_id,
)
if selectors.instantiation_id:
instantiation_query = instantiation_query.filter(
OrganizationModelInstantiation.id == selectors.instantiation_id
)
for row in _bounded_rows(
instantiation_query.order_by(OrganizationModelInstantiation.id)
):
records.append(
_record(
"organizations_model_instantiation",
row.id,
"organization_model_governance_evidence",
"Organization model instantiation attribution",
{
"match_fields": ["instantiated_by_account_id"],
"template_id": row.template_id,
"template_version_id": row.template_version_id,
"source_definition_sha256": row.source_definition_sha256,
"status": row.status,
},
observed_at=row.updated_at,
retention_reason=(
"Model-instantiation attribution is retained to explain which "
"reviewed institutional model became tenant-owned."
),
)
)
upgrade_query = db.query(OrganizationModelUpgrade).filter(
OrganizationModelUpgrade.tenant_id == tenant_id,
or_(
OrganizationModelUpgrade.requested_by_account_id
== selectors.account_id,
OrganizationModelUpgrade.applied_by_account_id == selectors.account_id,
OrganizationModelUpgrade.cancelled_by_account_id
== selectors.account_id,
),
)
if selectors.upgrade_id:
upgrade_query = upgrade_query.filter(
OrganizationModelUpgrade.id == selectors.upgrade_id
)
for row in _bounded_rows(upgrade_query.order_by(OrganizationModelUpgrade.id)):
records.append(
_record(
"organizations_model_upgrade",
row.id,
"organization_model_governance_evidence",
"Organization model upgrade attribution",
{
"match_fields": _actor_match_fields(
row,
selectors.account_id,
(
"requested_by_account_id",
"applied_by_account_id",
"cancelled_by_account_id",
),
),
"template_id": row.template_id,
"source_instantiation_id": row.source_instantiation_id,
"source_template_version_id": (row.source_template_version_id),
"target_template_version_id": (row.target_template_version_id),
"status": row.status,
"revision": row.revision,
"base_definition_sha256": row.base_definition_sha256,
"local_definition_sha256": row.local_definition_sha256,
"target_definition_sha256": row.target_definition_sha256,
"applied_at": _iso(row.applied_at),
"cancelled_at": _iso(row.cancelled_at),
},
observed_at=row.updated_at,
retention_reason=(
"Upgrade request, application, and cancellation attribution "
"is retained as organization-model change evidence."
),
)
)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Organizations DSAR result limit exceeded; narrow the subject selectors."
)
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Organizations DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
actions.append(
DsarErasureActionRef(
action_id=(
f"organizations:retain:{record.resource_type}:"
f"{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=record.retention_reason
or "Organization-model governance evidence must be retained.",
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Organizations DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable:
raise ValueError(
"Organizations DSAR does not publish executable erasure actions."
)
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Organization-model change attribution is retained as "
"institutional governance evidence."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
account_ids = {
value
for item in (
subject.account_id,
subject.external_references.get("organizations.account"),
subject.external_references.get("access.account"),
)
if (value := _normalized_id(item))
}
if len(account_ids) > 1:
return None
return _SubjectSelectors(
account_id=next(iter(account_ids), None),
instantiation_id=_normalized_id(
subject.external_references.get("organizations.model_instantiation")
),
upgrade_id=_normalized_id(
subject.external_references.get("organizations.model_upgrade")
),
)
def _actor_match_fields(
row: object,
account_id: str,
fields: Sequence[str],
) -> list[str]:
return [field for field in fields if getattr(row, field, None) == account_id]
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "organizations" or record.module_id != "organizations":
raise ValueError("Organizations DSAR received a foreign provider record.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "organizations" or action.module_id != "organizations":
raise ValueError("Organizations DSAR received a foreign provider action.")
def _record(
resource_type: str,
resource_id: str,
category: str,
title: str,
data: Mapping[str, object],
*,
observed_at: datetime | None,
retention_reason: str,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="organizations",
module_id="organizations",
resource_type=resource_type,
resource_id=resource_id,
category=category,
title=title,
data=data,
observed_at=observed_at,
immutable_evidence=True,
retention_reason=retention_reason,
source_path="/admin?section=tenant-organization-settings",
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Organizations DSAR provider requires a SQLAlchemy session.")
return value
def _bounded_rows(query: object) -> list[object]:
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Organizations DSAR match limit exceeded; narrow the subject selectors."
)
return rows
def _normalized_id(value: object) -> str | None:
if value is None:
return None
value = str(value).strip()
return value or None
def _iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.isoformat()
__all__ = ["ORGANIZATIONS_DSAR_CAPABILITY", "OrganizationsDsarProvider"]
+206 -3
View File
@@ -8,6 +8,8 @@ from govoplan_core.core.access import (
)
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
@@ -18,6 +20,7 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
@@ -28,6 +31,7 @@ from govoplan_core.core.organizations import (
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
from govoplan_organizations.backend.dsar_provider import ORGANIZATIONS_DSAR_CAPABILITY
ORGANIZATIONS_READ_SCOPES = (
@@ -130,10 +134,17 @@ def _organization_directory(context: ModuleContext) -> object:
return SqlOrganizationDirectory()
def _organizations_dsar_provider(context: ModuleContext) -> object:
del context
from govoplan_organizations.backend.dsar_provider import OrganizationsDsarProvider
return OrganizationsDsarProvider()
manifest = ModuleManifest(
id="organizations",
name="Organizations",
version="0.1.15",
version="0.1.21",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -148,6 +159,10 @@ manifest = ModuleManifest(
name="organizations.hierarchy_directory",
version="0.1.0",
),
ModuleInterfaceProvider(
name=ORGANIZATIONS_DSAR_CAPABILITY,
version="0.1.0",
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -181,6 +196,17 @@ manifest = ModuleManifest(
order=70,
),
),
product_areas=(
ProductAreaContribution(
id="people-responsibility",
module_id="organizations",
label="i18n:govoplan-core.product_area.people_responsibility",
icon="users",
description="i18n:govoplan-core.product_area.people_responsibility_description",
surface_ids=("organizations.nav.organizations", "organizations.route.organizations"),
order=70,
),
),
view_surfaces=(
ViewSurface(
id="organizations.admin.tenant",
@@ -224,8 +250,112 @@ manifest = ModuleManifest(
capability_factories={
CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory,
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (_organization_directory),
ORGANIZATIONS_DSAR_CAPABILITY: _organizations_dsar_provider,
},
capability_documentation={
ORGANIZATIONS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Organizations data-subject request provider",
summary=(
"Finds tenant-scoped account attribution on organization-model "
"instantiations and upgrades without exporting opaque model payloads."
),
contract_version="0.1.0",
documentation_types=("admin",),
audience=("privacy_officer", "organization_admin", "records_manager"),
),
},
documentation=(
DocumentationTopic(
id="organizations.workspace-layout",
title="Organizations workspace layout",
summary="Find workspace actions and read consistently arranged content.",
body="Documentation books sit immediately beside the visible heading or contextual label for "
"Organizations, organization settings and record dialogs, not among operational action "
"buttons. Field help remains beside its label. "
"Organization model tables use the complete card width; headings and creation actions stay in the card header, while the shared DataGrid owns filtering, sizing, pagination, and row actions. Unit types, structures, relation types, function types, units, relations, and functions follow the same layout. Model upgrades keep their meaningful version and decision context separate from the result table. Administrators and configurators must use Core's table-card layout instead of local padding or width overrides. The change does not modify model permissions, effective dates, or template-copy semantics.",
layer="static",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator"),
order=5,
translations={"de": {
"title": "Organisationen: Aufbau des Arbeitsbereichs",
"summary": "Arbeitsbereichsaktionen finden und einheitlich angeordnete Inhalte lesen.",
"body": "Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
"Kontextbezeichnung für Organisationen, Organisationseinstellungen und Eintragsdialoge, nicht "
"zwischen ausführbaren Aktionsschaltflächen. Feldhilfe bleibt neben der Feldbezeichnung. "
"Tabellen des Organisationsmodells nutzen die gesamte Kartenbreite. Überschrift und Anlegen-Aktionen bleiben im Kartenkopf; das gemeinsame DataGrid übernimmt Filter, Größe, Seitennavigation und Zeilenaktionen. Einheitentypen, Strukturen, Beziehungstypen, Funktionstypen, Einheiten, Beziehungen und Funktionen folgen demselben Layout. Modell-Upgrades behalten ihren relevanten Versions- und Entscheidungskontext getrennt von der Ergebnistabelle. Administratoren und Konfiguratoren verwenden Cores Tabellenkartenlayout statt lokaler Innenabstands- oder Breitenregeln. Rechte, Gültigkeitsdaten und das Kopierprinzip der Vorlagen ändern sich dadurch nicht.",
}},
),
DocumentationTopic(
id="organizations.privacy.data-subject-requests",
title="Review Organizations data in a data-subject request",
summary=(
"Collect tenant-scoped organization-model change attribution while "
"preserving institutional governance evidence."
),
body=(
"Organizations stores institutional units, structures, relations, and "
"functions rather than personal incumbency. IDM owns the links between "
"identities and functions and must answer for those assignments. The "
"Organizations DSAR provider therefore searches only corroborated account "
"attribution on tenant model instantiations and upgrade requests, "
"applications, or cancellations. It exports the affected template and "
"version references, status, revision, timestamps, and integrity hashes. "
"Global template authorship, unrelated institutional model objects, "
"template definitions, upgrade previews and decisions, provenance, "
"idempotency keys, request digests, opaque settings, and other tenants are "
"excluded. The attribution is retained as institutional model-change "
"evidence and the provider publishes no automatic erasure action."
),
layer="static",
documentation_types=("admin",),
audience=(
"privacy_officer",
"organization_admin",
"records_manager",
"operator",
),
related_modules=("access", "audit", "idm", "records"),
links=(
DocumentationLink(
label="Data-subject requests",
href="/admin?section=tenant-data-subject-requests",
kind="runtime",
),
DocumentationLink(
label="Organizations administration",
href="/admin?section=tenant-organization-settings",
kind="runtime",
),
),
metadata={
"kind": "guide",
"help_contexts": [
"organizations.admin.tenant",
"organizations.admin.template-upgrades",
],
},
order=24,
translations={
"de": {
"title": "Organizations-Daten in einer Datenschutzanfrage prüfen",
"summary": (
"Mandantenbezogene Zuordnungen von Änderungen am Organisationsmodell sammeln und institutionelle Governance-Nachweise bewahren."
),
"body": (
"Organizations speichert institutionelle Einheiten, Strukturen, Beziehungen und Funktionen statt persönlicher "
"Funktionsbesetzungen. IDM führt die Verknüpfungen zwischen Identitäten und Funktionen und beantwortet Anfragen "
"zu diesen Zuordnungen. Der DSAR-Anbieter von Organizations sucht deshalb nur bestätigte Kontozuordnungen an "
"Mandantenmodellinstanzen sowie Anträgen, Ausführungen oder Abbrüchen von Upgrades. Er gibt betroffene Vorlagen- "
"und Versionsverweise, Status, Revision, Zeitangaben und Integritätsprüfsummen aus. Globale Vorlagenurheberschaft, "
"fremde institutionelle Modellobjekte, Vorlagendefinitionen, Upgrade-Vorschauen und -Entscheidungen, Provenienz, "
"Idempotenzschlüssel, Anfrageprüfsummen, undurchsichtige Einstellungen und andere Mandanten bleiben ausgeschlossen. "
"Die Zuordnung bleibt als institutioneller Nachweis einer Modelländerung erhalten; der Anbieter veröffentlicht "
"keine automatische Löschaktion."
),
}
},
),
DocumentationTopic(
id="organizations.template-upgrades",
title="Upgrade a tenant organization model",
@@ -272,6 +402,24 @@ manifest = ModuleManifest(
],
},
order=27,
translations={
"de": {
"title": "Organisationsmodell eines Mandanten aktualisieren",
"summary": (
"Eine unveränderliche Systemvorlagenversion mit dem aktuellen mandanteneigenen Modell vergleichen, bevor ein Upgrade ausdrücklich angewendet wird."
),
"body": (
"In der Organizations-Administration wird eine Vorschau für eine neuere veröffentlichte Version der vom "
"Mandanten verwendeten Vorlage erstellt. Der gespeicherte Drei-Wege-Vergleich trennt kompatible Ergänzungen, "
"kompatible Änderungen, reine Mandantenabweichungen, destruktive Neuzuordnungen und ungültige Verweise. Reine "
"Mandantenänderungen bleiben erhalten. Konfliktbehaftete oder destruktive Einträge erfordern eine ausdrückliche "
"Entscheidung zum Behalten, Ersetzen oder zu einer begrenzten Zuordnung. Das Anwenden der geprüften Vorschau "
"erzeugt eine neue mandanteneigene Instanz und ersetzt die frühere Provenienz; eine laufende Vererbung entsteht "
"nie. Eine veraltete Vorschau wird abgewiesen, wenn sich Mandantenmodell oder eine der Vorlagenversionen geändert "
"hat. Ein Abbruch bewahrt den Prüfdatensatz, ohne Organisationsdaten zu verändern."
),
}
},
),
DocumentationTopic(
id="organizations.model",
@@ -286,6 +434,9 @@ manifest = ModuleManifest(
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator"),
conditions=(
DocumentationCondition(any_scopes=ORGANIZATIONS_READ_SCOPES),
),
related_modules=("tenancy", "access", "idm", "policy", "audit"),
links=(
DocumentationLink(
@@ -300,7 +451,7 @@ manifest = ModuleManifest(
),
),
metadata={
"kind": "guide",
"kind": "workflow",
"help_contexts": [
"organizations.workspace",
"organizations.model",
@@ -312,6 +463,22 @@ manifest = ModuleManifest(
],
},
order=25,
translations={
"de": {
"title": "Organisationsmodell",
"summary": (
"Organizations führt Einheiten, Hierarchie und Funktionen. IDM verknüpft Identitäten mit Funktionen; Access bildet angenommene Fakten auf Rollen und Rechte ab."
),
"body": (
"Organisationseinheitsarten, Strukturen und Beziehungsarten modellieren die Selbstbeschreibung der Institution. "
"Eine konkrete Organisationseinheit kann gleichzeitig an mehreren Strukturen teilnehmen, etwa einer "
"Arbeitgeberhierarchie und einer akademischen Struktur. Funktionen beschreiben Verantwortlichkeiten in "
"Organisationseinheiten. IDM verknüpft Identitäten mit diesen Funktionen; Access bildet angenommene Fakten auf "
"Rollen und Rechte ab. Eine Funktion beweist für sich weder Mandat, Zuständigkeit, Entscheidungsbefugnis noch "
"Unterschriftsbefugnis; diese wirksamen institutionellen Fakten gehören in einen getrennten Anbietervertrag."
),
}
},
),
DocumentationTopic(
id="organizations.reference.fields-and-consequences",
@@ -328,7 +495,11 @@ manifest = ModuleManifest(
"downstream routing. Deactivation retains the record and evidence but "
"removes it from active selection. Delegation and act-in-place flags only "
"describe permitted organizational semantics; Access and governed workflows "
"still decide effective authority. When configured, a recorded change-request "
"still decide effective authority. A delegable function permits a bounded "
"substitute to act as themself; act-in-place permits an explicitly selected "
"representation context that retains the real and represented accounts. Both "
"remain source-linked, time-bounded, revocable facts in IDM rather than inferred "
"group membership. When configured, a recorded change-request "
"ID is required before model mutations. Organization settings are tenant-owned "
"and do not inherit a live global hierarchy."
),
@@ -368,6 +539,38 @@ manifest = ModuleManifest(
},
},
order=26,
translations={
"de": {
"title": "Felder und Folgen des Organisationsmodells",
"summary": (
"Mandanteneigene Modelldefinitionen, konkrete Einheiten, Beziehungen, Funktionen, Governance-Verweise und Lebenszykluszustände unterscheiden."
),
"body": (
"Einheitsarten, Strukturen, Beziehungsarten und Funktionsarten definieren das mandanteneigene Modell. Einheiten, "
"Beziehungen und Funktionen sind konkrete institutionelle Fakten darin. Slugs sind stabile Integrationsverweise; "
"Änderungen an Eltern und Beziehungen wirken auf Hierarchietraversierung und nachgelagerte Weiterleitung. "
"Deaktivierung bewahrt Datensatz und Nachweise, entfernt ihn aber aus der aktiven Auswahl. Kennzeichen für "
"Delegation und Handeln-an-Stelle beschreiben nur zulässige Organisationssemantik; Access und gesteuerte Workflows "
"entscheiden weiterhin über wirksame Befugnis. Eine delegierbare Funktion erlaubt einer begrenzten Vertretung, als "
"sie selbst zu handeln; Handeln-an-Stelle erlaubt einen ausdrücklich gewählten Vertretungskontext, der reales und "
"vertretenes Konto bewahrt. Beides bleibt in IDM quellverknüpft, zeitlich begrenzt und widerrufbar, statt aus "
"Gruppenmitgliedschaft abgeleitet zu werden. Wenn konfiguriert, ist vor Modelländerungen eine erfasste "
"Änderungsantragskennung erforderlich. Organisationseinstellungen gehören dem Mandanten und erben keine laufende "
"globale Hierarchie."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"model_change": "Ändert zulässiges Vokabular und Einschränkungen mandanteneigener Organisationsfakten.",
"hierarchy_change": "Ändert Traversierung, Weiterleitung und geerbten institutionellen Kontext nachgelagerter Module.",
"deactivate": "Bewahrt Fakt und Nachweis und entfernt sie aus der aktiven Auswahl.",
"settings": "Ändert mandanteneigene Governance, Prüftiefe und Aufbewahrungsverhalten.",
}
}
},
),
),
architecture=declared_module_architecture(
+394
View File
@@ -0,0 +1,394 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_organizations.backend.db.models import (
OrganizationModelInstantiation,
OrganizationModelTemplate,
OrganizationModelTemplateVersion,
OrganizationModelUpgrade,
)
from govoplan_organizations.backend.dsar_provider import (
ORGANIZATIONS_DSAR_CAPABILITY,
OrganizationsDsarProvider,
)
from govoplan_organizations.backend.manifest import manifest
class _Registry:
def __init__(
self,
provider: OrganizationsDsarProvider,
*,
organizations_active: bool = True,
) -> None:
self.provider = provider
self.organizations_active = organizations_active
def capability_names(self):
return (ORGANIZATIONS_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "organizations"
def tenant_entitlement_resolver(self):
organizations_active = self.organizations_active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{
"effective_modules": (
("organizations",) if organizations_active else ()
)
},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "organizations"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != ORGANIZATIONS_DSAR_CAPABILITY:
raise KeyError(name)
class OrganizationsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(bind=self.engine)
self.session = sessionmaker(bind=self.engine, future=True)()
now = datetime.now(timezone.utc)
self.template = OrganizationModelTemplate(
id="template-1",
slug="municipality",
name="Municipality",
description="Global institutional template",
created_by_account_id="account-1",
settings={"secret": "global-template-settings-do-not-export"},
)
self.version_one = OrganizationModelTemplateVersion(
id="version-1",
template_id=self.template.id,
version="1.0.0",
status="published",
definition={"secret": "definition-one-do-not-export"},
definition_sha256="a" * 64,
published_at=now,
published_by_account_id="account-1",
)
self.version_two = OrganizationModelTemplateVersion(
id="version-2",
template_id=self.template.id,
version="2.0.0",
status="published",
definition={"secret": "definition-two-do-not-export"},
definition_sha256="b" * 64,
published_at=now,
published_by_account_id="account-1",
)
self.version_three = OrganizationModelTemplateVersion(
id="version-3",
template_id=self.template.id,
version="3.0.0",
status="published",
definition={"secret": "definition-three-do-not-export"},
definition_sha256="c" * 64,
)
self.instantiation = OrganizationModelInstantiation(
id="instantiation-1",
tenant_id="tenant-1",
template_id=self.template.id,
template_version_id=self.version_one.id,
source_definition_sha256=self.version_one.definition_sha256,
status="superseded",
instantiated_by_account_id="account-1",
object_counts={"units": 5},
provenance={"secret": "instantiation-provenance-do-not-export"},
)
unrelated_instantiation = OrganizationModelInstantiation(
id="instantiation-unrelated",
tenant_id="tenant-1",
template_id=self.template.id,
template_version_id=self.version_three.id,
source_definition_sha256=self.version_three.definition_sha256,
status="applied",
instantiated_by_account_id="account-other",
object_counts={"units": 9},
provenance={"secret": "unrelated-provenance-do-not-export"},
)
tenant_two_instantiation = OrganizationModelInstantiation(
id="instantiation-tenant-2",
tenant_id="tenant-2",
template_id=self.template.id,
template_version_id=self.version_one.id,
source_definition_sha256=self.version_one.definition_sha256,
status="applied",
instantiated_by_account_id="account-1",
object_counts={"units": 99},
provenance={"secret": "other-tenant-provenance-do-not-export"},
)
self.upgrade = OrganizationModelUpgrade(
id="upgrade-1",
tenant_id="tenant-1",
template_id=self.template.id,
source_instantiation_id=self.instantiation.id,
source_template_version_id=self.version_one.id,
target_template_version_id=self.version_two.id,
status="applied",
revision=2,
base_definition_sha256="d" * 64,
local_definition_sha256="e" * 64,
target_definition_sha256="f" * 64,
preview={"secret": "upgrade-preview-do-not-export"},
decisions={"secret": "upgrade-decisions-do-not-export"},
idempotency_key="idempotency-key-do-not-export",
request_digest="1" * 64,
requested_by_account_id="account-1",
applied_by_account_id="account-other",
applied_at=now,
provenance={"secret": "upgrade-provenance-do-not-export"},
)
unrelated_upgrade = OrganizationModelUpgrade(
id="upgrade-unrelated",
tenant_id="tenant-1",
template_id=self.template.id,
source_instantiation_id=unrelated_instantiation.id,
source_template_version_id=self.version_three.id,
target_template_version_id=self.version_two.id,
status="cancelled",
base_definition_sha256="2" * 64,
local_definition_sha256="3" * 64,
target_definition_sha256="4" * 64,
idempotency_key="unrelated-key",
request_digest="5" * 64,
requested_by_account_id="account-other",
cancelled_by_account_id="account-other",
)
tenant_two_upgrade = OrganizationModelUpgrade(
id="upgrade-tenant-2",
tenant_id="tenant-2",
template_id=self.template.id,
source_instantiation_id=tenant_two_instantiation.id,
source_template_version_id=self.version_one.id,
target_template_version_id=self.version_two.id,
status="previewed",
base_definition_sha256="6" * 64,
local_definition_sha256="7" * 64,
target_definition_sha256="8" * 64,
idempotency_key="tenant-two-key",
request_digest="9" * 64,
requested_by_account_id="account-1",
)
self.session.add_all(
[
self.template,
self.version_one,
self.version_two,
self.version_three,
self.instantiation,
unrelated_instantiation,
tenant_two_instantiation,
self.upgrade,
unrelated_upgrade,
tenant_two_upgrade,
]
)
self.session.commit()
self.provider = OrganizationsDsarProvider()
self.subject = DsarSubjectRef(account_id="account-1")
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
provided = {item.name for item in manifest.provides_interfaces}
self.assertIn(ORGANIZATIONS_DSAR_CAPABILITY, provided)
provider = manifest.capability_factories[ORGANIZATIONS_DSAR_CAPABILITY](None)
self.assertIsInstance(provider, DsarProvider)
def test_search_is_tenant_scoped_narrow_and_minimized(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
self.assertEqual(
{
"organizations_model_instantiation",
"organizations_model_upgrade",
},
{record.resource_type for record in records},
)
serialized = repr([record.to_dict() for record in records])
self.assertIn("instantiation-1", serialized)
self.assertIn("upgrade-1", serialized)
excluded = (
"global-template-settings-do-not-export",
"definition-one-do-not-export",
"instantiation-provenance-do-not-export",
"upgrade-preview-do-not-export",
"upgrade-decisions-do-not-export",
"idempotency-key-do-not-export",
"upgrade-provenance-do-not-export",
"instantiation-unrelated",
"upgrade-unrelated",
"instantiation-tenant-2",
"upgrade-tenant-2",
"other-tenant-provenance-do-not-export",
)
for value in excluded:
self.assertNotIn(value, serialized)
def test_conflicting_account_selectors_fail_closed(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"organizations.account": "account-other"},
),
)
self.assertEqual((), records)
def test_plan_retains_governance_evidence_and_execution_is_non_mutating(
self,
) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=records,
)
self.assertEqual({"retain"}, {action.kind for action in actions})
self.assertFalse(any(action.executable for action in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=actions,
request_id="dsar-organizations-1",
)
self.assertEqual({"blocked"}, {result.status for result in results})
self.assertIsNotNone(
self.session.get(OrganizationModelUpgrade, self.upgrade.id)
)
def test_execution_rejects_foreign_and_forged_executable_actions(self) -> None:
actions = (
DsarErasureActionRef(
action_id="idm:delete:upgrade:upgrade-1",
provider_id="idm",
module_id="idm",
kind="delete",
resource_type="organizations_model_upgrade",
resource_id=self.upgrade.id,
title="Foreign action",
rationale="Must be rejected",
executable=True,
),
DsarErasureActionRef(
action_id="organizations:delete:upgrade:upgrade-1",
provider_id="organizations",
module_id="organizations",
kind="delete",
resource_type="organizations_model_upgrade",
resource_id=self.upgrade.id,
title="Forged action",
rationale="Must be rejected",
executable=True,
),
)
for action in actions:
with self.assertRaises(ValueError):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(action,),
request_id="dsar-organizations-2",
)
def test_core_workflow_discovers_active_and_inactive_provider(self) -> None:
request = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-ORGANIZATIONS-1",
request_kind="access",
subject=self.subject,
purpose="Respond to an authorized privacy request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=request,
expected_revision=1,
)
self.assertEqual(["organizations"], request.coverage["covered_modules"])
disabled = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-ORGANIZATIONS-DISABLED",
request_kind="access",
subject=self.subject,
purpose="Verify disabled-module coverage.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, organizations_active=False),
row=disabled,
expected_revision=1,
)
self.assertEqual(0, disabled.search_result["record_count"])
self.assertEqual(
[ORGANIZATIONS_DSAR_CAPABILITY],
disabled.coverage["inactive_provider_capabilities"],
)
if __name__ == "__main__":
unittest.main()
@@ -2,6 +2,10 @@ from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
from govoplan_organizations.backend.manifest import manifest
@@ -39,6 +43,31 @@ class OrganizationsInterfaceDocumentationContractTests(unittest.TestCase):
)
self.assertIn("hierarchy_change", reference.metadata["consequence_classes"])
def test_german_reference_documentation_is_complete(self) -> None:
topics = manifest.documentation
self.assertEqual({
"organizations.workspace-layout",
"organizations.privacy.data-subject-requests",
"organizations.template-upgrades",
"organizations.model",
"organizations.reference.fields-and-consequences",
}, {topic.id for topic in topics})
for topic in topics:
translation = topic.translations.get("de", {})
self.assertTrue(translation.get("title"), topic.id)
self.assertTrue(translation.get("summary"), topic.id)
self.assertTrue(translation.get("body"), topic.id)
if localizable_documentation_metadata_keys(topic):
self.assertEqual("1", topic.structured_translation_version, topic.id)
self.assertIn("de", topic.structured_translations, topic.id)
self.assertEqual((), documentation_structured_translation_issues(topic))
kinds = {topic.metadata.get("kind") for topic in topics}
self.assertIn("workflow", kinds)
self.assertIn("reference", kinds)
workflow = next(topic for topic in topics if topic.metadata.get("kind") == "workflow")
self.assertTrue(workflow.conditions)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/organizations-webui",
"version": "0.1.15",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -19,7 +19,7 @@
"./styles/organizations.css": "./src/styles/organizations.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.45",
"@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
@@ -17,7 +17,8 @@ const translations = source("../src/i18n/generatedTranslations.ts");
assert(page.includes("DocumentationHelpLink") && settings.includes("DocumentationHelpLink"), "Workspace and tenant settings expose contextual documentation");
assert(page.includes("ActionBlockerHint") && settings.includes("ActionBlockerHint"), "Read-only states identify action, actor, and destination");
assert(page.includes("disabledReason") && settings.includes("disabledReason"), "Unavailable organization actions explain their state");
assert(page.includes("requestDiscard(() => void loadModel())") && settings.includes("requestDiscard(() => void load())"), "Reload preserves dirty organization drafts and settings");
assert(page.includes("<PageActionBar") && page.includes("reloadAction={{") && page.includes("onReload: () => void loadModel()") && settings.includes("requestDiscard(() => void load())"), "Reload uses the guarded semantic page action or the settings draft guard");
assert((page.match(/<Card bodyLayout="table"/g) ?? []).length === 7, "All seven organization collection tables use the shared edge-to-edge card body");
assert(page.includes("hasDirtyDraft ? requestDiscard(discardDrafts)"), "Editor close uses the shared unsaved-change guard");
assert(page.includes("ORGANIZATIONS_FIELD_DOCUMENTATION"), "Model fields link to stable consequence documentation");
assert(patterns.includes('topicId: "organizations.model"') && patterns.includes('topicId: "organizations.reference.fields-and-consequences"'), "Organizations uses manifest-backed help references");
@@ -1,6 +1,7 @@
import { MetricGrid } from "@govoplan/core-webui";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Eye, GitCompareArrows, Play, RefreshCw, X } from "lucide-react";
import {
import { ActionToolbar,
Button,
Card,
ConfirmDialog,
@@ -177,18 +178,20 @@ export default function OrganizationTemplateUpgradePanel({ settings, auth }: { s
return <>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
<Card title="Organization template upgrades" actions={<Button onClick={() => void load()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>}>
<Card bodyLayout="table" title="Organization template upgrades" actions={<Button onClick={() => void load()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>}>
<LoadingFrame loading={loading} label="Loading organization template upgrade state">
<div className="metric-grid compact">
<div className="content-pad">
<MetricGrid density="compact">
<MetricCard label="Applied version" value={currentVersion?.version ?? "Custom model"} tone="info" />
<MetricCard label="Available upgrades" value={targets.length} tone={targets.length ? "info" : "good"} />
<MetricCard label="Open previews" value={pending.length} tone={pending.length ? "warning" : "good"} />
<MetricCard label="Copy semantics" value="Tenant-owned" tone="good" />
</div>
</MetricGrid>
<p className="muted small-note">Template versions are immutable sources. A tenant model never live-inherits changes: every upgrade is a recorded three-way comparison, explicit decision set, and confirmed new instantiation.</p>
<div className="organization-upgrade-toolbar">
<ActionToolbar className="organization-upgrade-toolbar">
<FormField label="Published target version"><select value={targetVersionId} disabled={!canWrite || busy || !targets.length} onChange={(event) => setTargetVersionId(event.target.value)}>{targets.length ? targets.map((version) => <option key={version.id} value={version.id}>{templateVersionLabel(templates, version)}</option>) : <option value="">No newer published version</option>}</select></FormField>
<Button variant="primary" onClick={() => void createPreview()} disabled={!canWrite || busy || !targetVersionId}><GitCompareArrows aria-hidden="true" /> Create preview</Button>
</ActionToolbar>
</div>
<div className="organization-upgrade-table"><DataGrid id="organization-model-upgrades" rows={upgrades} columns={upgradeColumns} initialFit="container" getRowKey={(row) => row.id} emptyText={current ? "No organization template upgrades have been previewed." : "This tenant model was not instantiated from a system template."} /></div>
</LoadingFrame>
@@ -196,12 +199,12 @@ export default function OrganizationTemplateUpgradePanel({ settings, auth }: { s
<Dialog open={Boolean(selected)} title="Organization model upgrade preview" className="organization-upgrade-dialog" onClose={() => !busy && setSelected(null)} closeDisabled={busy} footer={<><Button onClick={() => setSelected(null)} disabled={busy}>Close</Button>{selected?.status === "previewed" && <><Button variant="danger" onClick={() => setCancelTarget(selected)} disabled={!canWrite || busy}>Cancel preview</Button><Button variant="primary" onClick={() => setApplyConfirmation(true)} disabled={applyDisabled}><Play aria-hidden="true" /> Review and apply</Button></>}</>}>
{selected && <>
<div className="metric-grid compact">
<MetricGrid density="compact">
<MetricCard label="Changes" value={selected.preview.entries.length} tone="info" />
<MetricCard label="Required decisions" value={selected.preview.requires_decisions} tone={selected.preview.requires_decisions ? "warning" : "good"} />
<MetricCard label="Invalid references" value={selected.preview.blocking_invalid_references} tone={selected.preview.blocking_invalid_references ? "danger" : "good"} />
<MetricCard label="Status" value={humanize(selected.status)} tone="info" />
</div>
</MetricGrid>
<p className="muted small-note">Compatible additions and non-conflicting changes apply automatically. Local-only divergence is preserved. Destructive remapping and competing edits require an explicit bounded decision.</p>
<div className="organization-upgrade-diff"><DataGrid id={`organization-model-upgrade-diff-${selected.id}`} rows={selected.preview.entries} columns={diffColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="The versions and tenant model are equivalent." /></div>
<FormField label="Approved change request (when required by tenant policy)"><input value={changeRequestId} disabled={!canWrite || busy || selected.status !== "previewed"} onChange={(event) => setChangeRequestId(event.target.value)} /></FormField>
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import {
import { ContentGrid, FormGrid,
ActionBlockerHint,
AdminPageLayout,
Button,
@@ -111,7 +111,7 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
return (
<AdminPageLayout
title="i18n:govoplan-organizations.organization_settings.c9ab9829"
title="i18n:govoplan-organizations.organization_settings.c9ab9829" titleHelp={<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />}
description="i18n:govoplan-organizations.organization_settings_description.35dc9f10"
loading={loading}
loadingLabel="i18n:govoplan-organizations.loading_organization_settings.c6008db8"
@@ -119,7 +119,7 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
success={success}
actions={(
<>
<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />
<Button
onClick={() => requestDiscard(() => void load())}
disabled={Boolean(reloadDisabledReason)}
@@ -154,7 +154,7 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
documentation={ORGANIZATIONS_DOCUMENTATION}
/>
)}
<div className="organizations-settings-grid">
<ContentGrid columns={1}>
<Card title="i18n:govoplan-organizations.model_governance.6aa18fd0">
<div className="settings-list">
<ToggleSwitch
@@ -176,7 +176,7 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
</Card>
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
<div className="admin-form-grid two-columns">
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
<FormField
label="i18n:govoplan-organizations.audit_detail_level.7397355d"
help={organizationWriteReason(canWrite, busy, "settings")}
@@ -190,6 +190,8 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
label="i18n:govoplan-organizations.change_retention_days.71bbd140"
help={organizationWriteReason(canWrite, busy, "settings")}
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
helpContextId="organizations.field.audit-retention"
helpModuleId="organizations"
>
<input
type="number"
@@ -200,10 +202,10 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
onChange={(event) => setDraft({ ...draft, change_retention_days: event.target.value === "" ? null : Math.max(0, Number(event.target.value)) })}
/>
</FormField>
</div>
</FormGrid>
<p className="muted small-note">i18n:govoplan-organizations.audit_retention_help.42dec57d</p>
</Card>
</div>
</ContentGrid>
<OrganizationTemplateUpgradePanel settings={settings} auth={auth} />
</AdminPageLayout>
);
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { Edit3, Plus, RefreshCw } from "lucide-react";
import {
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent, type JSX } from "react";
import { Edit3, Plus } from "lucide-react";
import { FormLayout, ActionToolbar,
ActionBlockerHint,
AdminIconButton,
ApiError,
@@ -8,14 +8,14 @@ import {
Card,
DataGrid,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
ExplorerTree,
FormField,
IconButton,
LoadingFrame,
ModuleSubnav,
PageTitle,
PageActionBar,
PageLayout,
StatusBadge,
TableActionGroup,
ToggleSwitch,
@@ -26,6 +26,7 @@ import {
useUnsavedDraftGuard,
usePlatformUiCapabilities,
useViewSurfaces,
WorkspaceLayout,
type ApiSettings,
type AuthInfo,
type DataGridColumn,
@@ -281,7 +282,7 @@ function mapById<T extends { id: string }>(items: T[]): Map<string, T> {
function sectionGroupsFor(sections: ReadonlySet<OrganizationSection>): ModuleSubnavGroup<OrganizationSection>[] {
return SECTION_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => sections.has(item.id))
items: group.items.filter((item) => "id" in item && sections.has(item.id))
})).filter((group) => group.items.length > 0);
}
@@ -308,14 +309,14 @@ function activeStatus(active: boolean): JSX.Element {
return <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-organizations.active.a733b809" : "i18n:govoplan-organizations.inactive.09af574c"} />;
}
function SluggedFields({
function SluggedFields<T extends SluggedDraft>({
draft,
onChange,
disabled,
disabledReason
}: {
draft: SluggedDraft;
onChange: (next: SluggedDraft) => void;
draft: T;
onChange: (next: T) => void;
disabled: boolean;
disabledReason?: string;
}) {
@@ -998,28 +999,29 @@ export default function OrganizationsPage({
: undefined;
const content = (
<div className={`${mode === "workspace" ? "content-pad " : ""}organizations-page ${mode === "admin" ? "organizations-admin-page" : ""}`.trim()}>
<div className="page-heading split organizations-heading">
<div>
<PageTitle loading={loading}>{title}</PageTitle>
<p>{description}</p>
</div>
<div className="organizations-toolbar">
<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />
<Button
type="button"
onClick={() => requestDiscard(() => void loadModel())}
disabled={Boolean(reloadDisabledReason)}
disabledReason={reloadDisabledReason}
title="i18n:govoplan-organizations.reload.cce71553"
>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-organizations.reload.cce71553
</Button>
</div>
</div>
<>
<PageLayout
archetype="workspace"
title={title} titleHelp={<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />}
description={description}
actions={<PageActionBar
variant="workspace"
refreshable
reloadAction={{
onReload: () => void loadModel(),
disabled: Boolean(reloadDisabledReason),
disabledReason: reloadDisabledReason,
title: "i18n:govoplan-organizations.reload.cce71553"
}}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
/>}
mode={mode === "workspace" ? "workspace" : "embedded"}
headerLoading={loading}
error={error}
success={error ? "" : success}
className={`organizations-page ${mode === "admin" ? "organizations-admin-page" : ""}`.trim()}
headerClassName="organizations-heading"
>
{!canWriteActive && (
<ActionBlockerHint
reason={{
@@ -1044,38 +1046,45 @@ export default function OrganizationsPage({
{active === "relations" && renderRelationsSection()}
{active === "functions" && renderFunctionsSection()}
</LoadingFrame>
</PageLayout>
{renderEditorDialog()}
</div>
</>
);
if (mode === "admin") return content;
return (
<div className="workspace organizations-workspace">
<ModuleSubnav
active={active}
groups={visibleSectionGroups}
onSelect={(section) => requestDiscard(() => setActive(section))}
/>
<main className="workspace-content">
{content}
</main>
</div>
<WorkspaceLayout
className="organizations-workspace"
primarySize="wide"
primary={(
<ModuleSubnav
active={active}
groups={visibleSectionGroups}
onSelect={(section) => requestDiscard(() => setActive(section))}
/>
)}
primaryLabel="i18n:govoplan-organizations.organization_model.5945c48a"
contentLabel={title}
documentationType="user"
>
{content}
</WorkspaceLayout>
);
function renderModelSection() {
return (
<div className="organizations-table-stack">
<Card title="i18n:govoplan-organizations.unit_types.c7afc174" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit_type.58f2c05a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openUnitTypeCreate} />}>
<Card bodyLayout="table" title="i18n:govoplan-organizations.unit_types.c7afc174" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit_type.58f2c05a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openUnitTypeCreate} />}>
<DataGrid id="organizations-unit-types" rows={model.unit_types} columns={unitTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_unit_types_found.c81fb2a7" initialFit="container" />
</Card>
<Card title="i18n:govoplan-organizations.structures.f9b7f3b4" actions={<AdminIconButton label="i18n:govoplan-organizations.add_structure.b722042a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openStructureCreate} />}>
<Card bodyLayout="table" title="i18n:govoplan-organizations.structures.f9b7f3b4" actions={<AdminIconButton label="i18n:govoplan-organizations.add_structure.b722042a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openStructureCreate} />}>
<DataGrid id="organizations-structures" rows={model.structures} columns={structureColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_structures_found.20b382d0" initialFit="container" />
</Card>
<Card title="i18n:govoplan-organizations.relation_types.e5890528" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation_type.2ad19d03" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openRelationTypeCreate} />}>
<Card bodyLayout="table" title="i18n:govoplan-organizations.relation_types.e5890528" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation_type.2ad19d03" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openRelationTypeCreate} />}>
<DataGrid id="organizations-relation-types" rows={model.relation_types} columns={relationTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relation_types_found.6f90bb81" initialFit="container" />
</Card>
<Card title="i18n:govoplan-organizations.function_types.172c01fe" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function_type.90d793f1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openFunctionTypeCreate} />}>
<Card bodyLayout="table" title="i18n:govoplan-organizations.function_types.172c01fe" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function_type.90d793f1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openFunctionTypeCreate} />}>
<DataGrid id="organizations-function-types" rows={model.function_types} columns={functionTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_function_types_found.5eef31b1" initialFit="container" />
</Card>
</div>
@@ -1086,9 +1095,9 @@ export default function OrganizationsPage({
return (
<div className="organizations-table-stack">
<Card title="i18n:govoplan-organizations.organization_tree.e5bfb195" actions={<AdminIconButton label="i18n:govoplan-organizations.add_root_unit.1ef8a9f5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
<div className="explorer-tree-toolbar">
<ActionToolbar className="explorer-tree-toolbar">
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} disabledReason={busy ? ORGANIZATIONS_INTERFACE_I18N.busy : undefined} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
</div>
</ActionToolbar>
{model.units.length ? (
<ExplorerTree
nodes={unitsByParentId.get("") ?? []}
@@ -1120,7 +1129,7 @@ export default function OrganizationsPage({
/>
) : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
</Card>
<Card title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
<Card bodyLayout="table" title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
<DataGrid id="organizations-units" rows={model.units} columns={unitColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />
</Card>
</div>
@@ -1130,7 +1139,7 @@ export default function OrganizationsPage({
function renderRelationsSection() {
return (
<div className="organizations-table-stack">
<Card title="i18n:govoplan-organizations.relations.1c796711" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation.6b6e67ea" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={openRelationCreate} />}>
<Card bodyLayout="table" title="i18n:govoplan-organizations.relations.1c796711" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation.6b6e67ea" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={openRelationCreate} />}>
<DataGrid id="organizations-relations" rows={model.relations} columns={relationColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relations_found.4e75b11d" initialFit="container" />
</Card>
</div>
@@ -1140,7 +1149,7 @@ export default function OrganizationsPage({
function renderFunctionsSection() {
return (
<div className="organizations-table-stack">
<Card title="i18n:govoplan-organizations.functions.805dc49b" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function.6abafee0" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteFunctions || busy} disabledReason={organizationWriteReason(canWriteFunctions, busy, "function")} onClick={() => openFunctionCreate()} />}>
<Card bodyLayout="table" title="i18n:govoplan-organizations.functions.805dc49b" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function.6abafee0" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteFunctions || busy} disabledReason={organizationWriteReason(canWriteFunctions, busy, "function")} onClick={() => openFunctionCreate()} />}>
<DataGrid id="organizations-functions" rows={model.functions} columns={functionColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_functions_found.54e759a0" initialFit="container" />
</Card>
</div>
@@ -1215,16 +1224,16 @@ export default function OrganizationsPage({
activeEditor === "functionType" ? submitFunctionType :
submitFunction;
return (
<Dialog
<Dialog variant="administration" size="wide"
open={Boolean(activeEditor)}
title={editorTitle()}
title={editorTitle()} titleHelp={<DocumentationHelpLink reference={ORGANIZATIONS_FIELD_DOCUMENTATION} />}
onClose={() => {
if (busy) return;
if (hasDirtyDraft) requestDiscard(discardDrafts);
else discardDrafts();
}}
closeDisabled={busy}
className="admin-dialog admin-dialog-wide organizations-editor-dialog"
className="organizations-editor-dialog"
footer={(
<>
<Button
@@ -1247,12 +1256,10 @@ export default function OrganizationsPage({
</>
)}
>
<div className="button-row organizations-dialog-help">
<DocumentationHelpLink reference={ORGANIZATIONS_FIELD_DOCUMENTATION} />
</div>
<form id={formId} className="admin-form-grid two-columns" onSubmit={(event) => void submit(event)}>
<FormLayout columns={2} gap="small" collapseAt="workspace" id={formId} className="" onSubmit={(event) => void submit(event)}>
{renderEditorFields()}
</form>
</FormLayout>
</Dialog>
);
}
+2
View File
@@ -66,6 +66,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.no_units_found.eea2dd0c": "No units found.",
"i18n:govoplan-organizations.none.334c4a4c": "None",
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organization model",
"i18n:govoplan-organizations.organization_model.5945c48a": "Organization model",
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Configure tenant-local unit types, parallel structures, relation types, and function types used by the organization module.",
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organizations",
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Configure tenant-level governance, audit, and retention behavior for organization changes.",
@@ -205,6 +206,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.no_units_found.eea2dd0c": "Keine Einheiten gefunden.",
"i18n:govoplan-organizations.none.334c4a4c": "Keine",
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organisationsmodell",
"i18n:govoplan-organizations.organization_model.5945c48a": "Organisationsmodell",
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Konfiguriere mandantenbezogene Einheitstypen, parallele Strukturen, Beziehungstypen und Funktionstypen des Organisationsmoduls.",
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organisationen",
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Konfiguriere Governance, Audit und Aufbewahrung für Organisationsänderungen auf Mandantenebene.",
-37
View File
@@ -1,35 +1,3 @@
.organizations-workspace {
grid-template-columns: 230px minmax(0, 1fr);
background: var(--bg);
}
.organizations-page {
display: grid;
gap: 18px;
width: 100%;
}
.organizations-admin-page {
max-width: 100%;
}
.organizations-settings-grid {
display: grid;
gap: 18px;
}
.organizations-heading {
margin-bottom: 4px;
}
.organizations-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.organizations-check-list {
display: grid;
gap: 10px;
@@ -72,11 +40,6 @@
color: var(--muted);
}
@media (max-width: 900px) {
.organizations-workspace {
grid-template-columns: 1fr;
}
}
.organization-upgrade-toolbar {
display: grid;
grid-template-columns: minmax(16rem, 1fr) auto;