Sync GovOPlaN module state

This commit is contained in:
2026-07-10 21:57:26 +02:00
parent 2751849af1
commit b09c1449b9
25 changed files with 2809 additions and 0 deletions

348
.gitignore vendored Normal file
View File

@@ -0,0 +1,348 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/

View File

@@ -15,6 +15,9 @@ vendor or protocol.
- inbound synchronization from external identity-management systems - inbound synchronization from external identity-management systems
- identity lifecycle import, update, disable, and reconciliation jobs - identity lifecycle import, update, disable, and reconciliation jobs
- identity-to-organization-function assignment links inside GovOPlaN
- bridge views that combine identity and organization facts, such as identity
candidates for organization function assignments
- mapping external identities, accounts, groups, organizational units, - mapping external identities, accounts, groups, organizational units,
functions, and attributes to GovOPlaN identity, organization, access, and functions, and attributes to GovOPlaN identity, organization, access, and
tenancy DTOs tenancy DTOs
@@ -50,6 +53,77 @@ No feature module should import IDM internals directly. Feature modules should
ask access, tenancy, or future directory capabilities for normalized identity ask access, tenancy, or future directory capabilities for normalized identity
facts. facts.
`govoplan-idm` has hard module dependencies on `govoplan-identity` and
`govoplan-organizations`, because its core value is connecting those normalized
facts. Identity remains the owner of identities/accounts. Organizations remains
the owner of units/functions.
## Current Runtime API
The first runtime API exposes identity lookup and organization-function
assignment links:
- `GET /api/v1/idm/settings`
- `PATCH /api/v1/idm/settings`
- `GET /api/v1/idm/organization-identities`
- `GET /api/v1/idm/organization-function-assignments`
- `POST /api/v1/idm/organization-function-assignments`
- `PATCH /api/v1/idm/organization-function-assignments/{assignment_id}`
The candidate endpoint returns searchable identity/account candidates for IDM
assignment forms. Assignment writes validate the identity/account link through
`govoplan-identity` and the function/unit scope through
`govoplan-organizations`. These endpoints exist only when IDM is enabled, which
implies both identity and organizations are enabled through module dependency
planning.
The WebUI exposed by this repository is a normal module UI at `/idm`. It is the
editing surface for identity-to-organization-function assignment links.
## Migration And Permission Transition
The initial IDM migration creates new IDM-owned tables only. No data is migrated
from the legacy access/organization assignment tables.
Canonical permissions for assignment work are:
- `idm:organization_identity:read`
- `idm:organization_assignment:read`
- `idm:organization_assignment:write`
- `idm:settings:read`
- `idm:settings:write`
During the transition, `organizations:function:assign` remains accepted by IDM
assignment and identity-candidate endpoints as a compatibility scope. New role
templates and documentation should use the IDM scopes. Organizations remains the
owner of units and function definitions; IDM owns identity-to-function
assignment links.
`govoplan-access` can consume IDM assignments through the optional
`idm.directory` capability when IDM is installed. This is deliberately optional:
access still works without IDM, and IDM does not require access to store or edit
assignment links. If the audit module is enabled, IDM records assignment and
settings audit events; otherwise the assignment API remains usable without an
audit hard dependency.
An IDM assignment does not grant application permissions by itself. Access grants
role-derived permissions only when an explicit external function role mapping
connects the organization function ID to an assignable role. Those mappings are
managed through the access API at
`/api/v1/admin/external-function-role-mappings`.
Tenant IDM settings can require recorded change requests before assignment
creates or updates are applied. The change-control key is
`idm.organization_assignments`.
Delegated and acting-for assignments are source-specific:
- `delegated` requires a source assignment for the same function, and the
organization function must allow delegation.
- `acting_for` requires a source assignment for the same function, an
acting-for account on that source identity, and the organization function must
allow acting in place.
## First Milestone ## First Milestone
The first useful milestone is a read-only synchronization preview: The first useful milestone is a read-only synchronization preview:

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "@govoplan/idm-webui",
"version": "0.1.6",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/idm.css": "./webui/src/styles/idm.css"
},
"files": [
"webui/src",
"README.md",
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

25
pyproject.toml Normal file
View File

@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-idm"
version = "0.1.6"
description = "GovOPlaN identity management bridge module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.6",
"govoplan-identity>=0.1.6",
"govoplan-organizations>=0.1.6",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_idm = ["py.typed"]
[project.entry-points."govoplan.modules"]
idm = "govoplan_idm.backend.manifest:get_manifest"

View File

@@ -0,0 +1 @@
"""GovOPlaN identity management bridge module."""

View File

@@ -0,0 +1 @@
"""Backend integration for the GovOPlaN IDM module."""

View File

@@ -0,0 +1 @@
"""IDM API package."""

View File

@@ -0,0 +1 @@
"""IDM API v1 package."""

View File

@@ -0,0 +1,544 @@
from __future__ import annotations
from typing import Any, TypeVar
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.encoders import jsonable_encoder
from sqlalchemy import func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, require_any_scope
from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER
from govoplan_core.core.configuration_control import (
ConfigurationChangeApproval,
ConfigurationControlError,
ensure_configuration_change_allowed,
record_configuration_change_applied,
)
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
from govoplan_organizations.backend.db.models import OrganizationFunction
from .schemas import (
IdmSettingsItem,
IdmSettingsUpdateRequest,
OrganizationFunctionAssignmentCreateRequest,
OrganizationFunctionAssignmentItem,
OrganizationFunctionAssignmentList,
OrganizationFunctionAssignmentUpdateRequest,
OrganizationIdentityCandidate,
OrganizationIdentityCandidateList,
)
router = APIRouter(prefix="/idm", tags=["idm"])
ORGANIZATION_IDENTITY_READ_SCOPES = (
"idm:organization_identity:read",
"idm:organization_assignment:write",
"organizations:function:assign",
"admin:users:read",
)
IDM_ASSIGNMENT_READ_SCOPES = ("idm:organization_assignment:read", "idm:organization_assignment:write", "organizations:function:assign")
IDM_ASSIGNMENT_WRITE_SCOPES = ("idm:organization_assignment:write", "organizations:function:assign")
IDM_SETTINGS_READ_SCOPES = (
"idm:settings:read",
"idm:settings:write",
"idm:organization_assignment:read",
"idm:organization_assignment:write",
)
IDM_SETTINGS_WRITE_SCOPES = ("idm:settings:write",)
IDM_ASSIGNMENT_CHANGE_CONTROL_KEY = "idm.organization_assignments"
IDM_ASSIGNMENT_AUDIT_EVENT = "idm.organization_assignment.updated"
ASSIGNMENT_SOURCES = {"direct", "delegated", "acting_for", "directory", "governance", "system"}
ModelT = TypeVar("ModelT")
def _tenant_id(principal: ApiPrincipal) -> str:
return principal.tenant_id
def _not_found(label: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{label} not found")
def _conflict(message: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message)
def _invalid(message: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=message)
def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPException:
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": exc.code, "message": str(exc), "plan": exc.plan})
def _get_tenant_row(session: Session, model: type[ModelT], item_id: str, tenant_id: str, label: str) -> ModelT:
item = session.get(model, item_id)
if item is None or getattr(item, "tenant_id") != tenant_id:
raise _not_found(label)
return item
def _commit(session: Session, item: ModelT) -> ModelT:
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise _conflict("The IDM assignment conflicts with existing data.") from exc
session.refresh(item)
return item
def _row_fields(item: object) -> dict[str, Any]:
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
return {key: getattr(item, key) for key in keys}
def _jsonable(value: object) -> object:
return jsonable_encoder(value)
def _assignment_item(item: IdmOrganizationFunctionAssignment) -> OrganizationFunctionAssignmentItem:
return OrganizationFunctionAssignmentItem(**_row_fields(item))
def _settings_item(item: IdmTenantSettings) -> IdmSettingsItem:
return IdmSettingsItem(**_row_fields(item))
def _default_settings(tenant_id: str) -> IdmSettingsItem:
return IdmSettingsItem(
tenant_id=tenant_id,
require_assignment_change_requests=False,
audit_detail_level="standard",
change_retention_days=None,
settings={},
created_at=None,
updated_at=None,
)
def _ensure_identity(session: Session, identity_id: str) -> None:
identity = session.get(Identity, identity_id)
if identity is None:
raise _not_found("Identity")
def _ensure_account_link(session: Session, identity_id: str, account_id: str | None) -> None:
if account_id is None:
return
exists = (
session.query(IdentityAccountLink)
.filter(IdentityAccountLink.identity_id == identity_id, IdentityAccountLink.account_id == account_id)
.first()
)
if exists is None:
raise _invalid("Account is not linked to the selected identity.")
def _account_linked_to_identity(session: Session, identity_id: str, account_id: str) -> bool:
return (
session.query(IdentityAccountLink)
.filter(IdentityAccountLink.identity_id == identity_id, IdentityAccountLink.account_id == account_id)
.first()
is not None
)
def _ensure_assignment_workflow(
session: Session,
item: IdmOrganizationFunctionAssignment,
*,
tenant_id: str,
) -> None:
if item.source not in ASSIGNMENT_SOURCES:
raise _invalid("Assignment source is not supported.")
if item.valid_from is not None and item.valid_until is not None and item.valid_until <= item.valid_from:
raise _invalid("Valid until must be after valid from.")
if item.delegated_from_assignment_id is not None and item.delegated_from_assignment_id == item.id:
raise _invalid("A function assignment cannot delegate from itself.")
function = _get_tenant_row(session, OrganizationFunction, item.function_id, tenant_id, "Organization function")
base: IdmOrganizationFunctionAssignment | None = None
if item.delegated_from_assignment_id is not None:
base = _get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
if not base.is_active:
raise _invalid("Delegation source assignment must be active.")
if base.function_id != item.function_id:
raise _invalid("Delegated and acting-for assignments must use the same organization function as the source assignment.")
if item.source == "delegated":
if base is None:
raise _invalid("Delegated assignments require a source assignment.")
if not function.delegable:
raise _invalid("This organization function does not allow delegation.")
if item.acting_for_account_id is not None:
raise _invalid("Delegated assignments cannot set an acting-for account.")
if item.identity_id == base.identity_id and (item.account_id or "") == (base.account_id or ""):
raise _invalid("A delegated assignment must target another identity or account.")
elif item.source == "acting_for":
if base is None:
raise _invalid("Acting-for assignments require a source assignment.")
if not function.act_in_place_allowed:
raise _invalid("This organization function does not allow acting in place.")
if item.acting_for_account_id is None:
raise _invalid("Acting-for assignments require an acting-for account.")
if base.account_id is not None:
if item.acting_for_account_id != base.account_id:
raise _invalid("Acting-for account must match the source assignment account.")
elif not _account_linked_to_identity(session, base.identity_id, item.acting_for_account_id):
raise _invalid("Acting-for account must belong to the source assignment identity.")
if item.account_id == item.acting_for_account_id:
raise _invalid("The acting account and acting-for account must be different.")
else:
if item.delegated_from_assignment_id is not None:
raise _invalid("Only delegated or acting-for assignments can reference a source assignment.")
if item.acting_for_account_id is not None:
raise _invalid("Only acting-for assignments can set an acting-for account.")
def _requires_assignment_change_request(session: Session, tenant_id: str) -> bool:
item = session.query(IdmTenantSettings).filter(IdmTenantSettings.tenant_id == tenant_id).one_or_none()
return bool(item and item.require_assignment_change_requests)
def _actor_id(principal: ApiPrincipal) -> str:
return principal.membership_id or principal.account_id
def _payload_for_control(resource_type: str, operation: str, payload: object) -> dict[str, Any]:
if hasattr(payload, "model_dump"):
values = payload.model_dump(mode="json", exclude={"change_request_id"}, exclude_unset=True) # type: ignore[attr-defined]
else:
values = {}
return {"resource_type": resource_type, "operation": operation, "payload": values}
def _target_for_control(tenant_id: str, resource_type: str, operation: str, resource_id: str | None = None) -> dict[str, Any]:
target: dict[str, Any] = {"tenant_id": tenant_id, "resource_type": resource_type, "operation": operation}
if resource_id is not None:
target["resource_id"] = resource_id
return target
def _ensure_assignment_change_allowed(
session: Session,
principal: ApiPrincipal,
*,
tenant_id: str,
operation: str,
payload: object,
resource_id: str | None = None,
) -> tuple[ConfigurationChangeApproval | None, dict[str, Any], dict[str, Any]]:
value = _payload_for_control("organization_function_assignment", operation, payload)
target = _target_for_control(tenant_id, "organization_function_assignment", operation, resource_id)
if not _requires_assignment_change_request(session, tenant_id):
return None, target, value
try:
approval = ensure_configuration_change_allowed(
session,
key=IDM_ASSIGNMENT_CHANGE_CONTROL_KEY,
value=value,
actor_user_id=_actor_id(principal),
actor_scopes=tuple(principal.scopes),
change_request_id=getattr(payload, "change_request_id", None),
target=target,
)
except ConfigurationControlError as exc:
raise _configuration_control_http_error(exc) from exc
return approval, target, value
def _record_assignment_change_applied(
session: Session,
principal: ApiPrincipal,
*,
approval: ConfigurationChangeApproval | None,
target: dict[str, Any],
before: object,
after: object,
) -> None:
if approval is None:
return
record_configuration_change_applied(
session,
key=IDM_ASSIGNMENT_CHANGE_CONTROL_KEY,
before_value=before,
after_value=after,
actor_user_id=_actor_id(principal),
approval=approval,
target=target,
audit_event=IDM_ASSIGNMENT_AUDIT_EVENT,
)
session.commit()
def _audit_capability_available() -> bool:
registry = get_registry()
return bool(registry is not None and hasattr(registry, "has_capability") and registry.has_capability(CAPABILITY_AUDIT_RECORDER))
def _record_assignment_audit(
session: Session,
principal: ApiPrincipal,
*,
action: str,
resource_type: str,
resource_id: str,
before: object,
after: object,
) -> None:
if not _audit_capability_available():
return
audit_from_principal(
session,
principal,
action=action,
object_type=resource_type,
object_id=resource_id,
details={"before": before, "after": after},
commit=True,
)
@router.get("/settings", response_model=IdmSettingsItem)
def get_idm_settings(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*IDM_SETTINGS_READ_SCOPES)),
) -> IdmSettingsItem:
tenant_id = _tenant_id(principal)
item = session.query(IdmTenantSettings).filter(IdmTenantSettings.tenant_id == tenant_id).one_or_none()
return _settings_item(item) if item is not None else _default_settings(tenant_id)
@router.patch("/settings", response_model=IdmSettingsItem)
def update_idm_settings(
payload: IdmSettingsUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*IDM_SETTINGS_WRITE_SCOPES)),
) -> IdmSettingsItem:
tenant_id = _tenant_id(principal)
item = session.query(IdmTenantSettings).filter(IdmTenantSettings.tenant_id == tenant_id).one_or_none()
before = _jsonable(_row_fields(item)) if item is not None else None
if item is None:
item = IdmTenantSettings(
tenant_id=tenant_id,
require_assignment_change_requests=False,
audit_detail_level="standard",
change_retention_days=None,
settings={},
)
session.add(item)
fields = payload.model_fields_set
for field in ("require_assignment_change_requests", "audit_detail_level", "change_retention_days"):
if field in fields:
value = getattr(payload, field)
if field != "change_retention_days" and value is None:
raise _invalid(f"{field} cannot be empty.")
setattr(item, field, value)
if "settings" in fields:
if payload.settings is None:
raise _invalid("Settings cannot be empty.")
item.settings = payload.settings
result = _settings_item(_commit(session, item))
_record_assignment_audit(
session,
principal,
action="idm.settings.updated",
resource_type="idm_tenant_settings",
resource_id=tenant_id,
before=before,
after=result.model_dump(mode="json"),
)
return result
@router.get("/organization-function-assignments", response_model=OrganizationFunctionAssignmentList)
def list_organization_function_assignments(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_READ_SCOPES)),
) -> OrganizationFunctionAssignmentList:
tenant_id = _tenant_id(principal)
assignments = (
session.query(IdmOrganizationFunctionAssignment)
.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id)
.order_by(IdmOrganizationFunctionAssignment.created_at.asc())
.all()
)
return OrganizationFunctionAssignmentList(assignments=[_assignment_item(item) for item in assignments])
@router.post("/organization-function-assignments", response_model=OrganizationFunctionAssignmentItem, status_code=status.HTTP_201_CREATED)
def create_organization_function_assignment(
payload: OrganizationFunctionAssignmentCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_WRITE_SCOPES)),
) -> OrganizationFunctionAssignmentItem:
tenant_id = _tenant_id(principal)
approval, target, _value = _ensure_assignment_change_allowed(session, principal, tenant_id=tenant_id, operation="created", payload=payload)
_ensure_identity(session, payload.identity_id)
_ensure_account_link(session, payload.identity_id, payload.account_id)
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function")
item = IdmOrganizationFunctionAssignment(
tenant_id=tenant_id,
identity_id=payload.identity_id,
account_id=payload.account_id,
function_id=function.id,
organization_unit_id=function.organization_unit_id,
applies_to_subunits=payload.applies_to_subunits,
source=payload.source,
delegated_from_assignment_id=payload.delegated_from_assignment_id,
acting_for_account_id=payload.acting_for_account_id,
valid_from=payload.valid_from,
valid_until=payload.valid_until,
is_active=payload.is_active,
settings=payload.settings,
)
if item.delegated_from_assignment_id is not None:
_get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
_ensure_assignment_workflow(session, item, tenant_id=tenant_id)
session.add(item)
saved = _commit(session, item)
result = _assignment_item(saved)
after = result.model_dump(mode="json")
_record_assignment_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=after)
_record_assignment_audit(
session,
principal,
action="idm.organization_assignment.created",
resource_type="idm_organization_function_assignment",
resource_id=saved.id,
before=None,
after=after,
)
return result
@router.patch("/organization-function-assignments/{assignment_id}", response_model=OrganizationFunctionAssignmentItem)
def update_organization_function_assignment(
assignment_id: str,
payload: OrganizationFunctionAssignmentUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_WRITE_SCOPES)),
) -> OrganizationFunctionAssignmentItem:
tenant_id = _tenant_id(principal)
item = _get_tenant_row(session, IdmOrganizationFunctionAssignment, assignment_id, tenant_id, "Organization function assignment")
before = _jsonable(_row_fields(item))
approval, target, _value = _ensure_assignment_change_allowed(session, principal, tenant_id=tenant_id, operation="updated", payload=payload, resource_id=assignment_id)
if "function_id" in payload.model_fields_set:
if payload.function_id is None:
raise _invalid("Function is required.")
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function")
item.function_id = function.id
item.organization_unit_id = function.organization_unit_id
identity_id = payload.identity_id if "identity_id" in payload.model_fields_set else item.identity_id
account_id = payload.account_id if "account_id" in payload.model_fields_set else item.account_id
if "identity_id" in payload.model_fields_set:
if payload.identity_id is None:
raise _invalid("Identity is required.")
_ensure_identity(session, payload.identity_id)
item.identity_id = payload.identity_id
_ensure_account_link(session, identity_id, account_id)
if "source" in payload.model_fields_set and payload.source is None:
raise _invalid("Assignment source is required.")
if "applies_to_subunits" in payload.model_fields_set and payload.applies_to_subunits is None:
raise _invalid("Subunit applicability cannot be empty.")
if "is_active" in payload.model_fields_set and payload.is_active is None:
raise _invalid("Active state cannot be empty.")
if "settings" in payload.model_fields_set and payload.settings is None:
raise _invalid("Settings cannot be empty.")
for field in (
"account_id",
"applies_to_subunits",
"source",
"delegated_from_assignment_id",
"acting_for_account_id",
"valid_from",
"valid_until",
"is_active",
"settings",
):
if field in payload.model_fields_set:
setattr(item, field, getattr(payload, field))
_ensure_assignment_workflow(session, item, tenant_id=tenant_id)
saved = _commit(session, item)
result = _assignment_item(saved)
after = result.model_dump(mode="json")
_record_assignment_change_applied(session, principal, approval=approval, target=target, before=before, after=after)
_record_assignment_audit(
session,
principal,
action="idm.organization_assignment.updated",
resource_type="idm_organization_function_assignment",
resource_id=saved.id,
before=before,
after=after,
)
return result
@router.get("/organization-identities", response_model=OrganizationIdentityCandidateList)
def list_organization_identity_candidates(
query: str | None = Query(default=None, min_length=1, max_length=255),
limit: int = Query(default=25, ge=1, le=100),
include_inactive: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORGANIZATION_IDENTITY_READ_SCOPES)),
) -> OrganizationIdentityCandidateList:
del principal
identity_query = session.query(Identity)
if not include_inactive:
identity_query = identity_query.filter(Identity.is_active.is_(True))
if query:
pattern = f"%{query.strip().casefold()}%"
matching_account_links = session.query(IdentityAccountLink.identity_id).filter(
func.lower(IdentityAccountLink.account_id).like(pattern)
)
identity_query = identity_query.filter(
or_(
func.lower(Identity.id).like(pattern),
func.lower(Identity.display_name).like(pattern),
func.lower(Identity.external_subject).like(pattern),
Identity.id.in_(matching_account_links),
)
)
identities = identity_query.order_by(Identity.display_name.asc(), Identity.id.asc()).limit(limit).all()
identity_ids = [identity.id for identity in identities]
links_by_identity: dict[str, list[IdentityAccountLink]] = {identity_id: [] for identity_id in identity_ids}
if identity_ids:
links = (
session.query(IdentityAccountLink)
.filter(IdentityAccountLink.identity_id.in_(identity_ids))
.order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.account_id.asc())
.all()
)
for link in links:
links_by_identity.setdefault(link.identity_id, []).append(link)
return OrganizationIdentityCandidateList(
identities=[
_identity_candidate(identity, links_by_identity.get(identity.id, []))
for identity in identities
]
)
def _identity_candidate(identity: Identity, links: list[IdentityAccountLink]) -> OrganizationIdentityCandidate:
primary_link = next((link for link in links if link.is_primary), None)
return OrganizationIdentityCandidate(
id=identity.id,
display_name=identity.display_name,
external_subject=identity.external_subject,
source=identity.source,
primary_account_id=primary_link.account_id if primary_link is not None else None,
account_ids=[link.account_id for link in links],
status="active" if identity.is_active else "inactive",
)

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import Field
from pydantic import BaseModel
AuditDetailLevel = Literal["summary", "standard", "full"]
class OrganizationIdentityCandidate(BaseModel):
id: str
display_name: str | None = None
external_subject: str | None = None
source: str
primary_account_id: str | None = None
account_ids: list[str]
status: str
class OrganizationIdentityCandidateList(BaseModel):
identities: list[OrganizationIdentityCandidate]
class OrganizationFunctionAssignmentItem(BaseModel):
id: str
tenant_id: str
identity_id: str
account_id: str | None = None
function_id: str
organization_unit_id: str
applies_to_subunits: bool
source: str
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
class OrganizationFunctionAssignmentList(BaseModel):
assignments: list[OrganizationFunctionAssignmentItem]
class OrganizationFunctionAssignmentCreateRequest(BaseModel):
identity_id: str = Field(min_length=1)
function_id: str = Field(min_length=1)
account_id: str | None = None
applies_to_subunits: bool = False
source: str = "direct"
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool = True
settings: dict[str, Any] = Field(default_factory=dict)
change_request_id: str | None = None
class OrganizationFunctionAssignmentUpdateRequest(BaseModel):
identity_id: str | None = None
function_id: str | None = None
account_id: str | None = None
applies_to_subunits: bool | None = None
source: str | None = None
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool | None = None
settings: dict[str, Any] | None = None
change_request_id: str | None = None
class IdmSettingsItem(BaseModel):
tenant_id: str
require_assignment_change_requests: bool
audit_detail_level: AuditDetailLevel
change_retention_days: int | None = None
settings: dict[str, Any]
created_at: datetime | None = None
updated_at: datetime | None = None
class IdmSettingsUpdateRequest(BaseModel):
require_assignment_change_requests: bool | None = None
audit_detail_level: AuditDetailLevel | None = None
change_retention_days: int | None = Field(default=None, ge=0)
settings: dict[str, Any] | None = None

View File

@@ -0,0 +1 @@
"""IDM database models."""

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class IdmOrganizationFunctionAssignment(Base, TimestampMixin):
__tablename__ = "idm_organization_function_assignments"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"identity_id",
"function_id",
"organization_unit_id",
name="uq_idm_org_function_assignments_identity_scope",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
identity_id: Mapped[str] = mapped_column(ForeignKey("identity_identities.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
function_id: Mapped[str] = mapped_column(ForeignKey("organizations_functions.id", ondelete="CASCADE"), nullable=False, index=True)
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True)
applies_to_subunits: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
source: Mapped[str] = mapped_column(String(50), default="direct", nullable=False)
delegated_from_assignment_id: Mapped[str | None] = mapped_column(ForeignKey("idm_organization_function_assignments.id", ondelete="SET NULL"), nullable=True, index=True)
acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class IdmTenantSettings(Base, TimestampMixin):
__tablename__ = "idm_tenant_settings"
tenant_id: Mapped[str] = mapped_column(String(36), primary_key=True)
require_assignment_change_requests: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
audit_detail_level: Mapped[str] = mapped_column(String(20), default="standard", nullable=False)
change_retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
__all__ = ["IdmOrganizationFunctionAssignment", "IdmTenantSettings", "new_uuid"]

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
from sqlalchemy import or_
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
from govoplan_core.db.session import get_database
from govoplan_core.security.time import utc_now
from govoplan_identity.backend.db.models import IdentityAccountLink
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
from govoplan_organizations.backend.db.models import OrganizationFunction
def _status(active: bool) -> str:
return "active" if active else "inactive"
def _assignment_ref(item: IdmOrganizationFunctionAssignment) -> OrganizationFunctionAssignmentRef:
return OrganizationFunctionAssignmentRef(
id=item.id,
tenant_id=item.tenant_id,
identity_id=item.identity_id,
account_id=item.account_id,
function_id=item.function_id,
organization_unit_id=item.organization_unit_id,
applies_to_subunits=item.applies_to_subunits,
source=item.source, # type: ignore[arg-type]
delegated_from_assignment_id=item.delegated_from_assignment_id,
acting_for_account_id=item.acting_for_account_id,
valid_from=item.valid_from,
valid_until=item.valid_until,
status=_status(item.is_active), # type: ignore[arg-type]
)
class SqlIdmDirectory(IdmDirectory):
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
with get_database().session() as session:
item = session.get(IdmOrganizationFunctionAssignment, assignment_id)
return _assignment_ref(item) if item is not None else None
def organization_function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
return self._assignments_for_identity_ids(identity_ids=(identity_id,), tenant_id=tenant_id)
def organization_function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
with get_database().session() as session:
identity_ids = [
row[0]
for row in session.query(IdentityAccountLink.identity_id)
.filter(IdentityAccountLink.account_id == account_id)
.all()
]
if not identity_ids:
return ()
return self._assignments_for_identity_ids(identity_ids=tuple(identity_ids), tenant_id=tenant_id, account_id=account_id)
def _assignments_for_identity_ids(
self,
*,
identity_ids: tuple[str, ...],
tenant_id: str | None = None,
account_id: str | None = None,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
if not identity_ids:
return ()
now = utc_now()
with get_database().session() as session:
query = (
session.query(IdmOrganizationFunctionAssignment)
.join(OrganizationFunction, OrganizationFunction.id == IdmOrganizationFunctionAssignment.function_id)
.filter(
IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids),
IdmOrganizationFunctionAssignment.is_active.is_(True),
OrganizationFunction.is_active.is_(True),
or_(IdmOrganizationFunctionAssignment.valid_from.is_(None), IdmOrganizationFunctionAssignment.valid_from <= now),
or_(IdmOrganizationFunctionAssignment.valid_until.is_(None), IdmOrganizationFunctionAssignment.valid_until > now),
)
.order_by(IdmOrganizationFunctionAssignment.created_at.asc())
)
if account_id is not None:
query = query.filter(or_(IdmOrganizationFunctionAssignment.account_id.is_(None), IdmOrganizationFunctionAssignment.account_id == account_id))
if tenant_id is not None:
query = query.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id)
return tuple(_assignment_ref(item) for item in query.all())

View File

@@ -0,0 +1,186 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.db.base import Base
from govoplan_idm.backend.db import models as idm_models # noqa: F401 - populate metadata
IDM_READ_SCOPES = (
"idm:organization_assignment:read",
"idm:organization_assignment:write",
"idm:settings:read",
"organizations:function:assign",
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="IDM",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
PERMISSIONS = (
_permission(
"idm:organization_identity:read",
"View organization identity candidates",
"Search identities and account links for organization function assignments.",
),
_permission(
"idm:organization_assignment:read",
"View organization function assignments",
"Read identity-to-organization-function assignment links.",
),
_permission(
"idm:organization_assignment:write",
"Manage organization function assignments",
"Create and edit identity-to-organization-function assignment links.",
),
_permission(
"idm:settings:read",
"View IDM settings",
"Read IDM governance and assignment-change policy settings.",
),
_permission(
"idm:settings:write",
"Manage IDM settings",
"Update IDM governance and assignment-change policy settings.",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="idm_organization_mapper",
name="IDM organization mapper",
description="Resolve identity/account candidates and manage organization function assignment links.",
permissions=(
"idm:organization_identity:read",
"idm:organization_assignment:read",
"idm:organization_assignment:write",
"idm:settings:read",
"idm:settings:write",
),
),
)
def _route_factory(context: ModuleContext):
del context
from govoplan_idm.backend.api.v1.routes import router
return router
def _idm_directory(context: ModuleContext) -> object:
del context
from govoplan_idm.backend.directory import SqlIdmDirectory
return SqlIdmDirectory()
manifest = ModuleManifest(
id="idm",
name="IDM",
version="0.1.6",
dependencies=("identity", "organizations"),
optional_dependencies=("access", "audit"),
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_route_factory,
nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),),
frontend=FrontendModule(
module_id="idm",
package_name="@govoplan/idm-webui",
routes=(FrontendRoute(path="/idm", component="IdmPage", required_any=IDM_READ_SCOPES, order=72),),
nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),),
),
migration_spec=MigrationSpec(
module_id="idm",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
idm_models.IdmOrganizationFunctionAssignment,
idm_models.IdmTenantSettings,
label="IDM",
),
),
capability_factories={
CAPABILITY_IDM_DIRECTORY: _idm_directory,
},
documentation=(
DocumentationTopic(
id="idm.organization_identity_bridge",
title="Identity to organization bridge",
summary="IDM resolves which identities and accounts can be matched to organization function assignments.",
body=(
"Identity owns normalized identities and account links. Organizations owns units and functions. "
"IDM owns assignment links between identities and organization functions, including identity search for organization assignments and future synchronization/mapping workflows. "
"Access may consume these assignment links when IDM is installed, but IDM does not require access to evaluate rights."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator"),
related_modules=("identity", "organizations", "access"),
order=26,
),
DocumentationTopic(
id="idm.reference.assignment-governance",
title="IDM assignment governance",
summary="Tenant IDM settings can require approved change requests before identity-to-function assignments are applied.",
body=(
"Assignment links are high-impact because they can later feed access decisions. "
"Tenants can enable recorded change requests for assignment create and update operations. "
"The legacy organizations:function:assign scope remains accepted for transition, while new role templates should grant idm:organization_assignment:write."
),
layer="configured",
documentation_types=("admin",),
audience=("tenant_admin", "access_admin", "operator"),
related_modules=("identity", "organizations", "access", "audit", "policy"),
order=27,
),
DocumentationTopic(
id="idm.workflow.assign-function-to-identity",
title="Assign an organization function to an identity",
summary="IDM links identities or accounts to organization functions. Access can consume those accepted links when a function-to-role mapping exists.",
body=(
"Create the unit and function in Organizations first. Make sure the person and account exist in Identity. "
"Then create the assignment in IDM. Direct assignments state who holds the function. Delegated assignments require a source assignment and a delegable function. "
"Acting-for assignments require a source assignment, an acting account, and a function that allows acting in place. "
"Access maps accepted function facts to roles and rights; without such a mapping, the assignment is recorded but does not grant application permissions."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator", "user"),
related_modules=("identity", "organizations", "access"),
order=28,
),
),
)
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -0,0 +1 @@
"""IDM database migrations."""

View File

@@ -0,0 +1,139 @@
"""idm organization function assignments
Revision ID: 8f9a0b1c2d3e
Revises:
Create Date: 2026-07-10 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "8f9a0b1c2d3e"
down_revision = None
branch_labels = None
depends_on = None
def _tables() -> set[str]:
return set(sa.inspect(op.get_bind()).get_table_names())
def _indexes(table_name: str) -> set[str]:
if table_name not in _tables():
return set()
return {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table_name)}
def _create_index_if_missing(name: str, table_name: str, columns: list[str], *, unique: bool = False) -> None:
if table_name in _tables() and name not in _indexes(table_name):
op.create_index(name, table_name, columns, unique=unique)
def _drop_index_if_exists(name: str, table_name: str) -> None:
if table_name in _tables() and name in _indexes(table_name):
op.drop_index(name, table_name=table_name)
def upgrade() -> None:
if "idm_tenant_settings" not in _tables():
op.create_table(
"idm_tenant_settings",
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("require_assignment_change_requests", sa.Boolean(), nullable=False),
sa.Column("audit_detail_level", sa.String(length=20), nullable=False),
sa.Column("change_retention_days", sa.Integer(), nullable=True),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("tenant_id", name=op.f("pk_idm_tenant_settings")),
)
if "idm_organization_function_assignments" not in _tables():
op.create_table(
"idm_organization_function_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=36), nullable=True),
sa.Column("function_id", sa.String(length=36), nullable=False),
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("delegated_from_assignment_id", sa.String(length=36), nullable=True),
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["delegated_from_assignment_id"],
["idm_organization_function_assignments.id"],
name=op.f("fk_idm_organization_function_assignments_delegated_from_assignment_id_idm_organization_function_assignments"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["function_id"],
["organizations_functions.id"],
name=op.f("fk_idm_organization_function_assignments_function_id_organizations_functions"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["identity_id"],
["identity_identities.id"],
name=op.f("fk_idm_organization_function_assignments_identity_id_identity_identities"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["organization_unit_id"],
["organizations_units.id"],
name=op.f("fk_idm_organization_function_assignments_organization_unit_id_organizations_units"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_idm_organization_function_assignments")),
sa.UniqueConstraint(
"tenant_id",
"identity_id",
"function_id",
"organization_unit_id",
name="uq_idm_org_function_assignments_identity_scope",
),
)
for column in (
"account_id",
"acting_for_account_id",
"delegated_from_assignment_id",
"function_id",
"identity_id",
"organization_unit_id",
"tenant_id",
):
_create_index_if_missing(
op.f(f"ix_idm_organization_function_assignments_{column}"),
"idm_organization_function_assignments",
[column],
)
def downgrade() -> None:
for column in (
"tenant_id",
"organization_unit_id",
"identity_id",
"function_id",
"delegated_from_assignment_id",
"acting_for_account_id",
"account_id",
):
_drop_index_if_exists(
op.f(f"ix_idm_organization_function_assignments_{column}"),
"idm_organization_function_assignments",
)
if "idm_organization_function_assignments" in _tables():
op.drop_table("idm_organization_function_assignments")
if "idm_tenant_settings" in _tables():
op.drop_table("idm_tenant_settings")

View File

@@ -0,0 +1 @@
"""IDM migration revisions."""

View File

@@ -0,0 +1 @@

31
webui/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@govoplan/idm-webui",
"version": "0.1.6",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/idm.css": "./src/styles/idm.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

147
webui/src/api/idm.ts Normal file
View File

@@ -0,0 +1,147 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type OrganizationUnitItem = {
id: string;
tenant_id: string;
unit_type_id?: string | null;
parent_id?: string | null;
slug: string;
name: string;
description?: string | null;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationFunctionItem = {
id: string;
tenant_id: string;
function_type_id?: string | null;
organization_unit_id: string;
slug: string;
name: string;
description?: string | null;
delegable: boolean;
act_in_place_allowed: boolean;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationModel = {
units: OrganizationUnitItem[];
functions: OrganizationFunctionItem[];
};
export type IdentityOption = {
id: string;
display_name?: string | null;
external_subject?: string | null;
source: string;
primary_account_id?: string | null;
account_ids: string[];
status: string;
};
export type IdentityListResponse = {
identities: IdentityOption[];
};
export type OrganizationFunctionAssignmentItem = {
id: string;
tenant_id: string;
identity_id: string;
account_id?: string | null;
function_id: string;
organization_unit_id: string;
applies_to_subunits: boolean;
source: string;
delegated_from_assignment_id?: string | null;
acting_for_account_id?: string | null;
valid_from?: string | null;
valid_until?: string | null;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationFunctionAssignmentList = {
assignments: OrganizationFunctionAssignmentItem[];
};
export type IdmSettings = {
tenant_id: string;
require_assignment_change_requests: boolean;
audit_detail_level: "summary" | "standard" | "full";
change_retention_days?: number | null;
settings: Record<string, unknown>;
created_at?: string | null;
updated_at?: string | null;
};
export type OrganizationFunctionAssignmentPayload = {
identity_id: string;
function_id: string;
account_id?: string | null;
applies_to_subunits?: boolean;
source?: string;
delegated_from_assignment_id?: string | null;
acting_for_account_id?: string | null;
valid_from?: string | null;
valid_until?: string | null;
is_active?: boolean;
settings?: Record<string, unknown>;
change_request_id?: string | null;
};
export type IdmSettingsPayload = Partial<Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings">>;
function post<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
return apiFetch<T>(settings, path, { method: "POST", body: JSON.stringify(payload) });
}
function patch<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
return apiFetch<T>(settings, path, { method: "PATCH", body: JSON.stringify(payload) });
}
export function getOrganizationModel(settings: ApiSettings): Promise<OrganizationModel> {
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
}
export function getOrganizationFunctionAssignments(settings: ApiSettings): Promise<OrganizationFunctionAssignmentList> {
return apiFetch<OrganizationFunctionAssignmentList>(settings, "/api/v1/idm/organization-function-assignments");
}
export function getIdmSettings(settings: ApiSettings): Promise<IdmSettings> {
return apiFetch<IdmSettings>(settings, "/api/v1/idm/settings");
}
export function patchIdmSettings(settings: ApiSettings, payload: IdmSettingsPayload): Promise<IdmSettings> {
return patch(settings, "/api/v1/idm/settings", payload);
}
export function searchOrganizationIdentityOptions(settings: ApiSettings, query = "", limit = 50): Promise<IdentityListResponse> {
const params = new URLSearchParams();
const trimmed = query.trim();
if (trimmed) params.set("query", trimmed);
params.set("limit", String(limit));
return apiFetch<IdentityListResponse>(settings, `/api/v1/idm/organization-identities?${params.toString()}`);
}
export function createOrganizationFunctionAssignment(
settings: ApiSettings,
payload: OrganizationFunctionAssignmentPayload
): Promise<OrganizationFunctionAssignmentItem> {
return post(settings, "/api/v1/idm/organization-function-assignments", payload);
}
export function patchOrganizationFunctionAssignment(
settings: ApiSettings,
id: string,
payload: Partial<OrganizationFunctionAssignmentPayload>
): Promise<OrganizationFunctionAssignmentItem> {
return patch(settings, `/api/v1/idm/organization-function-assignments/${encodeURIComponent(id)}`, payload);
}

View File

@@ -0,0 +1,730 @@
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { Edit3, RefreshCw, Save, XCircle } from "lucide-react";
import {
ApiError,
Button,
Card,
DataGrid,
DismissibleAlert,
FormField,
LoadingFrame,
PageTitle,
StatusBadge,
hasScope,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo,
type DataGridColumn
} from "@govoplan/core-webui";
import {
createOrganizationFunctionAssignment,
getIdmSettings,
getOrganizationFunctionAssignments,
getOrganizationModel,
patchIdmSettings,
patchOrganizationFunctionAssignment,
searchOrganizationIdentityOptions,
type IdmSettings,
type IdentityOption,
type OrganizationFunctionAssignmentItem,
type OrganizationFunctionAssignmentPayload,
type OrganizationFunctionItem,
type OrganizationModel,
type OrganizationUnitItem
} from "../api/idm";
type IdmPageProps = {
settings: ApiSettings;
auth: AuthInfo;
};
type AssignmentDraft = {
identity_id: string;
account_id: string;
function_id: string;
applies_to_subunits: boolean;
source: string;
delegated_from_assignment_id: string;
acting_for_account_id: string;
is_active: boolean;
};
type SettingsDraft = {
require_assignment_change_requests: boolean;
audit_detail_level: "summary" | "standard" | "full";
change_retention_days: string;
};
const EMPTY_MODEL: OrganizationModel = {
units: [],
functions: []
};
const DEFAULT_IDM_SETTINGS: IdmSettings = {
tenant_id: "",
require_assignment_change_requests: false,
audit_detail_level: "standard",
change_retention_days: null,
settings: {}
};
const SOURCE_OPTIONS = [
{ value: "direct", label: "i18n:govoplan-idm.direct.7b2b1f51" },
{ value: "delegated", label: "i18n:govoplan-idm.delegated.b189f4b6" },
{ value: "acting_for", label: "i18n:govoplan-idm.acting_for.8650e6a6" },
{ value: "directory", label: "i18n:govoplan-idm.directory.12e2e859" },
{ value: "governance", label: "i18n:govoplan-idm.governance.b989a277" },
{ value: "system", label: "i18n:govoplan-idm.system.70c07e3f" }
];
function emptyAssignmentDraft(): AssignmentDraft {
return {
identity_id: "",
account_id: "",
function_id: "",
applies_to_subunits: false,
source: "direct",
delegated_from_assignment_id: "",
acting_for_account_id: "",
is_active: true
};
}
function textOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function assignmentPayload(draft: AssignmentDraft): OrganizationFunctionAssignmentPayload {
return {
identity_id: draft.identity_id,
account_id: textOrNull(draft.account_id),
function_id: draft.function_id,
applies_to_subunits: draft.applies_to_subunits,
source: draft.source,
delegated_from_assignment_id: textOrNull(draft.delegated_from_assignment_id),
acting_for_account_id: textOrNull(draft.acting_for_account_id),
is_active: draft.is_active,
settings: {}
};
}
function assignmentDraftFrom(item: OrganizationFunctionAssignmentItem): AssignmentDraft {
return {
identity_id: item.identity_id,
account_id: item.account_id ?? "",
function_id: item.function_id,
applies_to_subunits: item.applies_to_subunits,
source: item.source || "direct",
delegated_from_assignment_id: item.delegated_from_assignment_id ?? "",
acting_for_account_id: item.acting_for_account_id ?? "",
is_active: item.is_active
};
}
function isAssignmentDirty(draft: AssignmentDraft): boolean {
return Boolean(
draft.identity_id ||
draft.account_id ||
draft.function_id ||
draft.applies_to_subunits ||
draft.source !== "direct" ||
draft.delegated_from_assignment_id ||
draft.acting_for_account_id ||
!draft.is_active
);
}
function settingsDraftFrom(item: IdmSettings | null): SettingsDraft {
const source = item ?? DEFAULT_IDM_SETTINGS;
return {
require_assignment_change_requests: source.require_assignment_change_requests,
audit_detail_level: source.audit_detail_level,
change_retention_days: source.change_retention_days == null ? "" : String(source.change_retention_days)
};
}
function settingsPayload(draft: SettingsDraft, item: IdmSettings | null): Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings"> {
const trimmedDays = draft.change_retention_days.trim();
return {
require_assignment_change_requests: draft.require_assignment_change_requests,
audit_detail_level: draft.audit_detail_level,
change_retention_days: trimmedDays ? Number(trimmedDays) : null,
settings: item?.settings ?? {}
};
}
function isSettingsDirty(draft: SettingsDraft, item: IdmSettings | null): boolean {
const baseline = settingsDraftFrom(item);
return (
draft.require_assignment_change_requests !== baseline.require_assignment_change_requests ||
draft.audit_detail_level !== baseline.audit_detail_level ||
draft.change_retention_days.trim() !== baseline.change_retention_days.trim()
);
}
function mapById<T extends { id: string }>(items: T[]): Map<string, T> {
return new Map(items.map((item) => [item.id, item]));
}
function apiErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
try {
const parsed = JSON.parse(error.body) as { detail?: string | { message?: string } };
if (typeof parsed.detail === "string") return parsed.detail;
if (parsed.detail && typeof parsed.detail.message === "string") return parsed.detail.message;
} catch {
// Fall back to the transport message below.
}
return error.message;
}
if (error instanceof Error) return error.message;
return String(error);
}
function identityDisplay(identity: IdentityOption | undefined, fallbackId: string): JSX.Element {
if (!identity) return <span className="idm-id">{fallbackId}</span>;
const label = identity.display_name || identity.external_subject || identity.id;
return (
<span className="idm-identity">
<span>{label}</span>
<span className="idm-id">{identity.id}</span>
</span>
);
}
function functionLabel(item: OrganizationFunctionItem, unitById: ReadonlyMap<string, OrganizationUnitItem>): string {
const unitName = unitById.get(item.organization_unit_id)?.name;
return unitName ? `${item.name} (${unitName})` : item.name;
}
function assignmentOptionLabel(
item: OrganizationFunctionAssignmentItem,
identityById: ReadonlyMap<string, IdentityOption>,
functionById: ReadonlyMap<string, OrganizationFunctionItem>,
unitById: ReadonlyMap<string, OrganizationUnitItem>
): string {
const identity = identityById.get(item.identity_id);
const identityLabel = identity?.display_name || identity?.external_subject || item.identity_id;
const functionItem = functionById.get(item.function_id);
const functionText = functionItem ? functionLabel(functionItem, unitById) : item.function_id;
return `${identityLabel} - ${functionText}`;
}
function activeStatus(active: boolean): JSX.Element {
return <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-idm.active.7bd0e9f8" : "i18n:govoplan-idm.inactive.1baa5fba"} />;
}
function sourceLabel(value: string): string {
return SOURCE_OPTIONS.find((item) => item.value === value)?.label ?? value;
}
export default function IdmPage({ settings, auth }: IdmPageProps) {
const [model, setModel] = useState<OrganizationModel>(EMPTY_MODEL);
const [assignments, setAssignments] = useState<OrganizationFunctionAssignmentItem[]>([]);
const [idmSettings, setIdmSettings] = useState<IdmSettings | null>(null);
const [identityOptions, setIdentityOptions] = useState<IdentityOption[]>([]);
const [identitySearch, setIdentitySearch] = useState("");
const [identityLoading, setIdentityLoading] = useState(false);
const [identityLookupAvailable, setIdentityLookupAvailable] = useState(true);
const [actingForSearch, setActingForSearch] = useState("");
const [actingForOptions, setActingForOptions] = useState<IdentityOption[]>([]);
const [actingForLoading, setActingForLoading] = useState(false);
const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
const [settingsDraft, setSettingsDraft] = useState<SettingsDraft>(() => settingsDraftFrom(null));
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const canManage = hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
const canSearchIdentities = canManage || hasScope(auth, "idm:organization_identity:read") || hasScope(auth, "admin:users:read");
const canReadSettings = hasScope(auth, "idm:settings:read") || hasScope(auth, "idm:settings:write") || hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write");
const canManageSettings = hasScope(auth, "idm:settings:write");
const unitById = useMemo(() => mapById(model.units), [model.units]);
const functionById = useMemo(() => mapById(model.functions), [model.functions]);
const identityOptionById = useMemo(() => mapById(identityOptions), [identityOptions]);
const actingForOptionById = useMemo(() => mapById(actingForOptions), [actingForOptions]);
const selectedIdentity = identityOptionById.get(assignmentDraft.identity_id);
const identitySelectOptions = useMemo(() => {
if (!assignmentDraft.identity_id || identityOptionById.has(assignmentDraft.identity_id)) return identityOptions;
return [
{
id: assignmentDraft.identity_id,
display_name: assignmentDraft.identity_id,
external_subject: null,
source: "local",
primary_account_id: assignmentDraft.account_id || null,
account_ids: assignmentDraft.account_id ? [assignmentDraft.account_id] : [],
status: "active"
},
...identityOptions
];
}, [assignmentDraft.account_id, assignmentDraft.identity_id, identityOptionById, identityOptions]);
const accountIds = useMemo(() => {
const values = new Set(selectedIdentity?.account_ids ?? []);
if (assignmentDraft.account_id) values.add(assignmentDraft.account_id);
return Array.from(values).sort((left, right) => left.localeCompare(right));
}, [assignmentDraft.account_id, selectedIdentity]);
const assignmentOptions = useMemo(
() => assignments.filter((item) => item.id !== editingAssignmentId && (!assignmentDraft.function_id || item.function_id === assignmentDraft.function_id)),
[assignmentDraft.function_id, assignments, editingAssignmentId]
);
const assignmentById = useMemo(() => mapById(assignments), [assignments]);
const sourceAssignment = assignmentDraft.delegated_from_assignment_id ? assignmentById.get(assignmentDraft.delegated_from_assignment_id) : undefined;
const actingForAccountIds = useMemo(() => {
const values = new Set<string>();
if (sourceAssignment?.account_id) values.add(sourceAssignment.account_id);
const sourceIdentity = sourceAssignment ? identityOptionById.get(sourceAssignment.identity_id) ?? actingForOptionById.get(sourceAssignment.identity_id) : undefined;
for (const accountId of sourceIdentity?.account_ids ?? []) values.add(accountId);
for (const identity of actingForOptions) {
for (const accountId of identity.account_ids) values.add(accountId);
}
if (assignmentDraft.acting_for_account_id) values.add(assignmentDraft.acting_for_account_id);
return Array.from(values).sort((left, right) => left.localeCompare(right));
}, [actingForOptionById, actingForOptions, assignmentDraft.acting_for_account_id, identityOptionById, sourceAssignment]);
const initialFunctionFilter = useMemo(() => {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("function_id") || "";
}, []);
const hasDirtyAssignmentDraft = isAssignmentDirty(assignmentDraft);
const hasDirtySettingsDraft = canReadSettings && isSettingsDirty(settingsDraft, idmSettings);
const hasDirtyDraft = hasDirtyAssignmentDraft || hasDirtySettingsDraft;
const loadData = useCallback(async () => {
setLoading(true);
setError("");
try {
const [nextModel, nextAssignments, nextSettings] = await Promise.all([
getOrganizationModel(settings),
getOrganizationFunctionAssignments(settings),
canReadSettings ? getIdmSettings(settings).catch(() => null) : Promise.resolve(null)
]);
setModel(nextModel);
setAssignments(nextAssignments.assignments);
setIdmSettings(nextSettings);
if (nextSettings) setSettingsDraft(settingsDraftFrom(nextSettings));
if (initialFunctionFilter && nextModel.functions.some((item) => item.id === initialFunctionFilter)) {
setAssignmentDraft((draft) => draft.function_id ? draft : { ...draft, function_id: initialFunctionFilter });
}
if (canSearchIdentities) {
try {
const identities = await searchOrganizationIdentityOptions(settings, "", 100);
setIdentityOptions(identities.identities);
setIdentityLookupAvailable(true);
} catch {
setIdentityLookupAvailable(false);
}
} else {
setIdentityLookupAvailable(false);
}
} catch (caught) {
setError(apiErrorMessage(caught));
} finally {
setLoading(false);
}
}, [canReadSettings, canSearchIdentities, initialFunctionFilter, settings]);
useEffect(() => {
void loadData();
}, [loadData]);
useEffect(() => {
if (!canSearchIdentities) return;
let disposed = false;
setIdentityLoading(true);
searchOrganizationIdentityOptions(settings, identitySearch, 50)
.then((payload) => {
if (!disposed) {
setIdentityOptions(payload.identities);
setIdentityLookupAvailable(true);
}
})
.catch(() => {
if (!disposed) setIdentityLookupAvailable(false);
})
.finally(() => {
if (!disposed) setIdentityLoading(false);
});
return () => {
disposed = true;
};
}, [canSearchIdentities, identitySearch, settings]);
useEffect(() => {
if (!canSearchIdentities || assignmentDraft.source !== "acting_for") return;
let disposed = false;
setActingForLoading(true);
searchOrganizationIdentityOptions(settings, actingForSearch, 50)
.then((payload) => {
if (!disposed) setActingForOptions(payload.identities);
})
.catch(() => {
if (!disposed) setActingForOptions([]);
})
.finally(() => {
if (!disposed) setActingForLoading(false);
});
return () => {
disposed = true;
};
}, [actingForSearch, assignmentDraft.source, canSearchIdentities, settings]);
const runAction = useCallback(async (action: () => Promise<unknown>, successMessage = ""): Promise<boolean> => {
setBusy(true);
setError("");
setSuccess("");
try {
await action();
if (successMessage) setSuccess(successMessage);
await loadData();
return true;
} catch (caught) {
setError(apiErrorMessage(caught));
return false;
} finally {
setBusy(false);
}
}, [loadData]);
const discardAssignmentDraft = useCallback(() => {
setAssignmentDraft(emptyAssignmentDraft());
setEditingAssignmentId(null);
}, []);
const discardDrafts = useCallback(() => {
discardAssignmentDraft();
setSettingsDraft(settingsDraftFrom(idmSettings));
}, [discardAssignmentDraft, idmSettings]);
const submitSettings = useCallback(async (): Promise<boolean> => {
if (!hasDirtySettingsDraft) return true;
if (!canManageSettings) {
setError("i18n:govoplan-idm.settings_write_permission_required.37f37efa");
return false;
}
const ok = await runAction(
() => patchIdmSettings(settings, settingsPayload(settingsDraft, idmSettings)),
"i18n:govoplan-idm.settings_updated.9bbd0867"
);
return ok;
}, [canManageSettings, hasDirtySettingsDraft, idmSettings, runAction, settings, settingsDraft]);
const submitAssignment = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canManage) {
setError("i18n:govoplan-idm.write_permission_required.c7dde7c6");
return false;
}
if (!assignmentDraft.identity_id) {
setError("i18n:govoplan-idm.identity_is_required.6ad4ee23");
return false;
}
if (!assignmentDraft.function_id) {
setError("i18n:govoplan-idm.function_is_required.5cce5b41");
return false;
}
const payload = assignmentPayload(assignmentDraft);
const ok = await runAction(
() => editingAssignmentId ? patchOrganizationFunctionAssignment(settings, editingAssignmentId, payload) : createOrganizationFunctionAssignment(settings, payload),
editingAssignmentId ? "i18n:govoplan-idm.assignment_updated.fbbf9bd6" : "i18n:govoplan-idm.assignment_added.91f1ee42"
);
if (ok) discardAssignmentDraft();
return ok;
}, [assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, settings]);
const saveDrafts = useCallback(async (): Promise<boolean> => {
if (hasDirtyAssignmentDraft && !(await submitAssignment())) return false;
if (hasDirtySettingsDraft && !(await submitSettings())) return false;
return true;
}, [hasDirtyAssignmentDraft, hasDirtySettingsDraft, submitAssignment, submitSettings]);
useUnsavedDraftGuard({
dirty: hasDirtyDraft,
onSave: saveDrafts,
onDiscard: discardDrafts,
title: "i18n:govoplan-core.unsaved_changes.29267269",
message: "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b"
});
const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
setEditingAssignmentId(item.id);
setAssignmentDraft(assignmentDraftFrom(item));
}, []);
const toggleAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
void runAction(() => patchOrganizationFunctionAssignment(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
function onIdentityChange(identityId: string) {
const identity = identityOptionById.get(identityId);
setAssignmentDraft({
...assignmentDraft,
identity_id: identityId,
account_id: identity?.primary_account_id ?? identity?.account_ids[0] ?? ""
});
}
function onSourceChange(source: string) {
setAssignmentDraft({
...assignmentDraft,
source,
delegated_from_assignment_id: source === "delegated" || source === "acting_for" ? assignmentDraft.delegated_from_assignment_id : "",
acting_for_account_id: source === "acting_for" ? assignmentDraft.acting_for_account_id : ""
});
}
const assignmentColumns: DataGridColumn<OrganizationFunctionAssignmentItem>[] = [
{
id: "identity",
header: "i18n:govoplan-idm.identity.544a8347",
minWidth: 220,
sortable: true,
filterable: true,
filterValue: (row) => identityOptionById.get(row.identity_id)?.display_name ?? row.identity_id,
render: (row) => identityDisplay(identityOptionById.get(row.identity_id), row.identity_id)
},
{
id: "account",
header: "i18n:govoplan-idm.account.2b2936f8",
minWidth: 180,
value: (row) => row.account_id ?? "",
render: (row) => row.account_id ? <span className="idm-id">{row.account_id}</span> : <span className="idm-empty">i18n:govoplan-idm.none.2baf5c66</span>
},
{
id: "function",
header: "i18n:govoplan-idm.function.e67d5aba",
minWidth: 220,
sortable: true,
filterable: true,
filterValue: (row) => `${functionById.get(row.function_id)?.name ?? ""} ${row.function_id}`.trim(),
render: (row) => {
const item = functionById.get(row.function_id);
return item ? functionLabel(item, unitById) : <span className="idm-id">{row.function_id}</span>;
}
},
{
id: "unit",
header: "i18n:govoplan-idm.unit.a94c2fbd",
minWidth: 180,
render: (row) => unitById.get(row.organization_unit_id)?.name ?? <span className="idm-id">{row.organization_unit_id}</span>
},
{
id: "subunits",
header: "i18n:govoplan-idm.applies_to_subunits.2e31b50b",
width: 150,
render: (row) => activeStatus(row.applies_to_subunits)
},
{
id: "source",
header: "i18n:govoplan-idm.source.d15b50c9",
width: 130,
value: (row) => row.source,
render: (row) => sourceLabel(row.source)
},
{
id: "status",
header: "i18n:govoplan-idm.status.9acb445d",
width: 120,
render: (row) => activeStatus(row.is_active)
},
{
id: "actions",
header: "",
width: 220,
sticky: "end",
render: (row) => (
<div className="idm-row-actions">
<Button type="button" variant="ghost" disabled={!canManage || busy} onClick={() => editAssignment(row)} title="i18n:govoplan-idm.edit.a5a0f3cc">
<Edit3 size={16} aria-hidden="true" /> i18n:govoplan-idm.edit.a5a0f3cc
</Button>
<Button type="button" variant={row.is_active ? "secondary" : "primary"} disabled={!canManage || busy} onClick={() => toggleAssignment(row)}>
{row.is_active ? "i18n:govoplan-idm.deactivate.585777c8" : "i18n:govoplan-idm.reactivate.e4871a43"}
</Button>
</div>
)
}
];
return (
<div className="content-pad idm-page">
<div className="page-heading split idm-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-idm.idm.61f4a7a2</PageTitle>
<p>i18n:govoplan-idm.identity_links_intro.45fed9dd</p>
</div>
<div className="idm-toolbar">
<Button type="button" onClick={() => void loadData()} disabled={loading || busy} title="i18n:govoplan-idm.reload.870ca3ec">
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-idm.reload.870ca3ec
</Button>
{hasDirtyDraft && (
<>
<Button type="button" onClick={() => void saveDrafts()} disabled={busy} title="i18n:govoplan-idm.save_drafts.32a0d60a">
<Save size={16} aria-hidden="true" /> i18n:govoplan-idm.save_drafts.32a0d60a
</Button>
<Button type="button" variant="ghost" onClick={discardDrafts} disabled={busy} title="i18n:govoplan-idm.discard_drafts.c0a86816">
<XCircle size={16} aria-hidden="true" /> i18n:govoplan-idm.discard_drafts.c0a86816
</Button>
</>
)}
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
{!canManage && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-idm.write_permission_required.c7dde7c6</DismissibleAlert>}
<LoadingFrame loading={loading || busy} label="i18n:govoplan-idm.loading_idm_assignments.0b1501bd">
<div className="idm-layout">
<div className="idm-editor-stack">
<Card title="i18n:govoplan-idm.assignment_editor.e20598e7">
<form className="idm-form-grid" onSubmit={(event) => void submitAssignment(event)}>
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf">
<input
value={identitySearch}
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
disabled={!canSearchIdentities || busy}
onChange={(event) => setIdentitySearch(event.target.value)}
/>
</FormField>
<FormField label="i18n:govoplan-idm.select_identity.91d31615">
<select value={assignmentDraft.identity_id} disabled={!canManage || busy} onChange={(event) => onIdentityChange(event.target.value)}>
<option value="">i18n:govoplan-idm.select_identity.91d31615</option>
{identitySelectOptions.map((item) => (
<option key={item.id} value={item.id}>{item.display_name || item.external_subject || item.id}</option>
))}
</select>
</FormField>
<FormField label="i18n:govoplan-idm.account.2b2936f8">
<select value={assignmentDraft.account_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })}>
<option value="">i18n:govoplan-idm.none.2baf5c66</option>
{accountIds.map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-idm.select_function.2bec86e0">
<select value={assignmentDraft.function_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, function_id: event.target.value })}>
<option value="">i18n:govoplan-idm.select_function.2bec86e0</option>
{model.functions.map((item) => <option key={item.id} value={item.id}>{functionLabel(item, unitById)}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-idm.source.d15b50c9">
<select value={assignmentDraft.source} disabled={!canManage || busy} onChange={(event) => onSourceChange(event.target.value)}>
{SOURCE_OPTIONS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</FormField>
{(assignmentDraft.source === "delegated" || assignmentDraft.source === "acting_for") && (
<FormField label="i18n:govoplan-idm.delegated_from_assignment_id.20d4a548">
<select value={assignmentDraft.delegated_from_assignment_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, delegated_from_assignment_id: event.target.value, acting_for_account_id: "" })}>
<option value="">i18n:govoplan-idm.none.2baf5c66</option>
{assignmentOptions.map((item) => (
<option key={item.id} value={item.id}>{assignmentOptionLabel(item, identityOptionById, functionById, unitById)}</option>
))}
</select>
</FormField>
)}
{assignmentDraft.source === "acting_for" && (
<>
<FormField label="i18n:govoplan-idm.acting_for_search.b7c526c7">
<input
value={actingForSearch}
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
disabled={!canSearchIdentities || busy}
onChange={(event) => setActingForSearch(event.target.value)}
/>
</FormField>
<FormField label="i18n:govoplan-idm.acting_for_account_id.5d7ade5b">
<select value={assignmentDraft.acting_for_account_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, acting_for_account_id: event.target.value })}>
<option value="">i18n:govoplan-idm.select_account.982ee1ad</option>
{actingForAccountIds.map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
</select>
</FormField>
</>
)}
<div className="idm-check-list">
<label>
<input type="checkbox" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits: event.target.checked })} />
<span>i18n:govoplan-idm.applies_to_subunits.2e31b50b</span>
</label>
<label>
<input type="checkbox" checked={assignmentDraft.is_active} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, is_active: event.target.checked })} />
<span>i18n:govoplan-idm.active.7bd0e9f8</span>
</label>
</div>
{!identityLookupAvailable && <p className="idm-muted wide">i18n:govoplan-idm.identity_lookup_unavailable.b76f7714</p>}
{identityLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_identities.f3b84693</p>}
{actingForLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e</p>}
{!model.functions.length && <p className="idm-muted wide">i18n:govoplan-idm.no_functions_available.51ba08eb</p>}
<div className="idm-form-actions wide">
<Button type="submit" variant="primary" disabled={!canManage || busy}>
{editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
</Button>
{editingAssignmentId && (
<Button type="button" variant="ghost" onClick={discardAssignmentDraft} disabled={busy}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
)}
</div>
</form>
</Card>
{canReadSettings && (
<Card title="i18n:govoplan-idm.idm_governance.6e4f3251">
<form className="idm-form-grid" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
<div className="idm-check-list wide">
<label>
<input
type="checkbox"
checked={settingsDraft.require_assignment_change_requests}
disabled={!canManageSettings || busy}
onChange={(event) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests: event.target.checked })}
/>
<span>i18n:govoplan-idm.require_assignment_change_requests.697718a1</span>
</label>
</div>
<FormField label="i18n:govoplan-idm.audit_detail_level.eb2e6fd2">
<select
value={settingsDraft.audit_detail_level}
disabled={!canManageSettings || busy}
onChange={(event) => setSettingsDraft({ ...settingsDraft, audit_detail_level: event.target.value as SettingsDraft["audit_detail_level"] })}
>
<option value="summary">i18n:govoplan-idm.summary.f1c5b7ab</option>
<option value="standard">i18n:govoplan-idm.standard.6edc51aa</option>
<option value="full">i18n:govoplan-idm.full.7f021a14</option>
</select>
</FormField>
<FormField label="i18n:govoplan-idm.change_retention_days.4a91f7d3">
<input
type="number"
min="0"
value={settingsDraft.change_retention_days}
disabled={!canManageSettings || busy}
onChange={(event) => setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })}
/>
</FormField>
<div className="idm-form-actions wide">
<Button type="submit" variant="primary" disabled={!canManageSettings || busy || !hasDirtySettingsDraft}>
i18n:govoplan-idm.save_settings.4602c430
</Button>
</div>
</form>
</Card>
)}
</div>
<Card title="i18n:govoplan-idm.assignments.a0d19ec5">
<DataGrid
id="idm-organization-function-assignments"
rows={assignments}
columns={assignmentColumns}
getRowKey={(row) => row.id}
emptyText="i18n:govoplan-idm.no_assignments.41193ce8"
initialFilters={initialFunctionFilter ? { function: initialFunctionFilter } : undefined}
initialFit="container"
/>
</Card>
</div>
</LoadingFrame>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
en: {
"i18n:govoplan-idm.account.2b2936f8": "Account",
"i18n:govoplan-idm.active.7bd0e9f8": "Active",
"i18n:govoplan-idm.acting_for.8650e6a6": "acting for",
"i18n:govoplan-idm.acting_for_account_id.5d7ade5b": "Acting for account ID",
"i18n:govoplan-idm.acting_for_search.b7c526c7": "Acting-for account search",
"i18n:govoplan-idm.add_assignment.08f2a0d5": "Add assignment",
"i18n:govoplan-idm.applies_to_subunits.2e31b50b": "Applies to subunits",
"i18n:govoplan-idm.assignment_added.91f1ee42": "Assignment added.",
"i18n:govoplan-idm.assignment_editor.e20598e7": "Assignment editor",
"i18n:govoplan-idm.assignment_updated.fbbf9bd6": "Assignment updated.",
"i18n:govoplan-idm.assignments.a0d19ec5": "Assignments",
"i18n:govoplan-idm.cancel_edit.ea4781e0": "Cancel edit",
"i18n:govoplan-idm.audit_detail_level.eb2e6fd2": "Audit detail level",
"i18n:govoplan-idm.change_retention_days.4a91f7d3": "Change retention days",
"i18n:govoplan-idm.deactivate.585777c8": "Deactivate",
"i18n:govoplan-idm.delegated.b189f4b6": "delegated",
"i18n:govoplan-idm.delegated_from_assignment_id.20d4a548": "Delegated from assignment",
"i18n:govoplan-idm.direct.7b2b1f51": "direct",
"i18n:govoplan-idm.directory.12e2e859": "directory",
"i18n:govoplan-idm.discard_drafts.c0a86816": "Discard drafts",
"i18n:govoplan-idm.edit.a5a0f3cc": "Edit",
"i18n:govoplan-idm.function.e67d5aba": "Function",
"i18n:govoplan-idm.function_is_required.5cce5b41": "Function is required.",
"i18n:govoplan-idm.full.7f021a14": "Full",
"i18n:govoplan-idm.governance.b989a277": "governance",
"i18n:govoplan-idm.identity.544a8347": "Identity",
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identity is required.",
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Link identities to organization functions. Organizations defines the functions; IDM owns who holds them.",
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identity lookup is unavailable.",
"i18n:govoplan-idm.identity_search.d3460fcf": "Identity search",
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM governance",
"i18n:govoplan-idm.idm.61f4a7a2": "IDM",
"i18n:govoplan-idm.inactive.1baa5fba": "Inactive",
"i18n:govoplan-idm.loading_idm_assignments.0b1501bd": "Loading IDM assignments...",
"i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e": "Loading acting-for accounts...",
"i18n:govoplan-idm.loading_identities.f3b84693": "Loading identities...",
"i18n:govoplan-idm.no_assignments.41193ce8": "No identity/function assignments found.",
"i18n:govoplan-idm.no_functions_available.51ba08eb": "No organization functions are available yet.",
"i18n:govoplan-idm.none.2baf5c66": "None",
"i18n:govoplan-idm.reactivate.e4871a43": "Reactivate",
"i18n:govoplan-idm.reload.870ca3ec": "Reload",
"i18n:govoplan-idm.require_assignment_change_requests.697718a1": "Require assignment change requests",
"i18n:govoplan-idm.save_drafts.32a0d60a": "Save drafts",
"i18n:govoplan-idm.save_settings.4602c430": "Save settings",
"i18n:govoplan-idm.search_identities.88a9ef15": "Search by name, subject, account, or ID",
"i18n:govoplan-idm.select_account.982ee1ad": "Select account",
"i18n:govoplan-idm.select_function.2bec86e0": "Select function",
"i18n:govoplan-idm.select_identity.91d31615": "Select identity",
"i18n:govoplan-idm.settings_updated.9bbd0867": "Settings updated.",
"i18n:govoplan-idm.settings_write_permission_required.37f37efa": "You do not have permission to manage IDM settings.",
"i18n:govoplan-idm.source.d15b50c9": "Source",
"i18n:govoplan-idm.standard.6edc51aa": "Standard",
"i18n:govoplan-idm.status.9acb445d": "Status",
"i18n:govoplan-idm.summary.f1c5b7ab": "Summary",
"i18n:govoplan-idm.system.70c07e3f": "system",
"i18n:govoplan-idm.unit.a94c2fbd": "Unit",
"i18n:govoplan-idm.update_assignment.e20f52aa": "Update assignment",
"i18n:govoplan-idm.view_assignments.2d40d6a5": "View assignments",
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "You do not have permission to manage IDM organization assignments."
},
de: {
"i18n:govoplan-idm.account.2b2936f8": "Konto",
"i18n:govoplan-idm.active.7bd0e9f8": "Aktiv",
"i18n:govoplan-idm.acting_for.8650e6a6": "in Vertretung",
"i18n:govoplan-idm.acting_for_account_id.5d7ade5b": "Konto-ID der Vertretung",
"i18n:govoplan-idm.acting_for_search.b7c526c7": "Suche nach vertretenem Konto",
"i18n:govoplan-idm.add_assignment.08f2a0d5": "Zuordnung hinzufügen",
"i18n:govoplan-idm.applies_to_subunits.2e31b50b": "Gilt für Untereinheiten",
"i18n:govoplan-idm.assignment_added.91f1ee42": "Zuordnung hinzugefügt.",
"i18n:govoplan-idm.assignment_editor.e20598e7": "Zuordnungseditor",
"i18n:govoplan-idm.assignment_updated.fbbf9bd6": "Zuordnung aktualisiert.",
"i18n:govoplan-idm.assignments.a0d19ec5": "Zuordnungen",
"i18n:govoplan-idm.cancel_edit.ea4781e0": "Bearbeitung abbrechen",
"i18n:govoplan-idm.audit_detail_level.eb2e6fd2": "Audit-Detailgrad",
"i18n:govoplan-idm.change_retention_days.4a91f7d3": "Änderungsaufbewahrung in Tagen",
"i18n:govoplan-idm.deactivate.585777c8": "Deaktivieren",
"i18n:govoplan-idm.delegated.b189f4b6": "delegiert",
"i18n:govoplan-idm.delegated_from_assignment_id.20d4a548": "Delegiert von Zuordnung",
"i18n:govoplan-idm.direct.7b2b1f51": "direkt",
"i18n:govoplan-idm.directory.12e2e859": "Verzeichnis",
"i18n:govoplan-idm.discard_drafts.c0a86816": "Entwürfe verwerfen",
"i18n:govoplan-idm.edit.a5a0f3cc": "Bearbeiten",
"i18n:govoplan-idm.function.e67d5aba": "Funktion",
"i18n:govoplan-idm.function_is_required.5cce5b41": "Funktion ist erforderlich.",
"i18n:govoplan-idm.full.7f021a14": "Vollständig",
"i18n:govoplan-idm.governance.b989a277": "Governance",
"i18n:govoplan-idm.identity.544a8347": "Identität",
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identität ist erforderlich.",
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Verknüpfe Identitäten mit Organisationsfunktionen. Organisationen definiert die Funktionen; IDM verwaltet, wer sie innehat.",
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identitätssuche ist nicht verfügbar.",
"i18n:govoplan-idm.identity_search.d3460fcf": "Identitätssuche",
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM-Governance",
"i18n:govoplan-idm.idm.61f4a7a2": "IDM",
"i18n:govoplan-idm.inactive.1baa5fba": "Inaktiv",
"i18n:govoplan-idm.loading_idm_assignments.0b1501bd": "IDM-Zuordnungen werden geladen...",
"i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e": "Vertretene Konten werden geladen...",
"i18n:govoplan-idm.loading_identities.f3b84693": "Identitäten werden geladen...",
"i18n:govoplan-idm.no_assignments.41193ce8": "Keine Identitäts-/Funktionszuordnungen gefunden.",
"i18n:govoplan-idm.no_functions_available.51ba08eb": "Es sind noch keine Organisationsfunktionen verfügbar.",
"i18n:govoplan-idm.none.2baf5c66": "Keine",
"i18n:govoplan-idm.reactivate.e4871a43": "Reaktivieren",
"i18n:govoplan-idm.reload.870ca3ec": "Neu laden",
"i18n:govoplan-idm.require_assignment_change_requests.697718a1": "Änderungsanträge für Zuordnungen verlangen",
"i18n:govoplan-idm.save_drafts.32a0d60a": "Entwürfe speichern",
"i18n:govoplan-idm.save_settings.4602c430": "Einstellungen speichern",
"i18n:govoplan-idm.search_identities.88a9ef15": "Nach Name, Subject, Konto oder ID suchen",
"i18n:govoplan-idm.select_account.982ee1ad": "Konto auswählen",
"i18n:govoplan-idm.select_function.2bec86e0": "Funktion auswählen",
"i18n:govoplan-idm.select_identity.91d31615": "Identität auswählen",
"i18n:govoplan-idm.settings_updated.9bbd0867": "Einstellungen aktualisiert.",
"i18n:govoplan-idm.settings_write_permission_required.37f37efa": "Du hast keine Berechtigung, IDM-Einstellungen zu verwalten.",
"i18n:govoplan-idm.source.d15b50c9": "Quelle",
"i18n:govoplan-idm.standard.6edc51aa": "Standard",
"i18n:govoplan-idm.status.9acb445d": "Status",
"i18n:govoplan-idm.summary.f1c5b7ab": "Zusammenfassung",
"i18n:govoplan-idm.system.70c07e3f": "System",
"i18n:govoplan-idm.unit.a94c2fbd": "Einheit",
"i18n:govoplan-idm.update_assignment.e20f52aa": "Zuordnung aktualisieren",
"i18n:govoplan-idm.view_assignments.2d40d6a5": "Zuordnungen anzeigen",
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "Du hast keine Berechtigung, IDM-Organisationszuordnungen zu verwalten."
}
};

1
webui/src/index.ts Normal file
View File

@@ -0,0 +1 @@
export { idmModule, default } from "./module";

72
webui/src/module.ts Normal file
View File

@@ -0,0 +1,72 @@
import { createElement, lazy } from "react";
import { Button, type OrganizationFunctionActionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/idm.css";
const IdmPage = lazy(() => import("./features/IdmPage"));
const idmReadScopes = [
"idm:organization_assignment:read",
"idm:organization_assignment:write",
"organizations:function:assign"
];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const organizationFunctionActions: OrganizationFunctionActionsUiCapability = {
actions: [
{
id: "idm.view-function-assignments",
label: "i18n:govoplan-idm.assignments.a0d19ec5",
anyOf: idmReadScopes,
order: 40,
render: ({ function: item }) => createElement(
Button,
{
type: "button",
variant: "ghost",
title: "i18n:govoplan-idm.view_assignments.2d40d6a5",
onClick: () => {
if (typeof window !== "undefined") {
window.location.href = `/idm?function_id=${encodeURIComponent(item.id)}`;
}
}
},
"i18n:govoplan-idm.assignments.a0d19ec5"
)
}
]
};
export const idmModule: PlatformWebModule = {
id: "idm",
label: "i18n:govoplan-idm.idm.61f4a7a2",
version: "0.1.6",
dependencies: ["identity", "organizations"],
translations,
navItems: [
{
to: "/idm",
label: "i18n:govoplan-idm.idm.61f4a7a2",
iconName: "users",
anyOf: idmReadScopes,
order: 72
}
],
routes: [
{
path: "/idm",
anyOf: idmReadScopes,
order: 72,
render: ({ settings, auth }) => createElement(IdmPage, { settings, auth })
}
],
uiCapabilities: {
"organizations.functionActions": organizationFunctionActions
}
};
export default idmModule;

100
webui/src/styles/idm.css Normal file
View File

@@ -0,0 +1,100 @@
.idm-page {
display: grid;
gap: 18px;
width: 100%;
max-width: 1480px;
}
.idm-heading {
margin-bottom: 4px;
}
.idm-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.idm-layout {
display: grid;
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.idm-editor-stack {
display: grid;
gap: 18px;
}
.idm-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.idm-form-grid .wide {
grid-column: 1 / -1;
}
.idm-check-list {
display: grid;
gap: 10px;
grid-column: 1 / -1;
}
.idm-check-list label {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text);
font-weight: 600;
}
.idm-form-actions,
.idm-row-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.idm-row-actions {
justify-content: flex-end;
}
.idm-identity {
display: grid;
gap: 2px;
min-width: 0;
}
.idm-id {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: var(--muted);
}
.idm-muted {
margin: 0;
color: var(--muted);
font-size: 13px;
}
.idm-empty {
color: var(--muted);
}
@media (max-width: 1100px) {
.idm-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.idm-form-grid {
grid-template-columns: 1fr;
}
}