Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53e947935a | |||
| 324c26da78 | |||
| 389f98e349 | |||
| ce9ef8d88f | |||
| 13bc3d3b4e | |||
| 3f5870281a | |||
| a46df85479 | |||
| c31581b1b9 | |||
| 26ae034153 | |||
| baa2143a26 | |||
| 8b1910b5b7 | |||
| d36bb94335 | |||
| 74034947c6 | |||
| c7183fe7f1 | |||
| 139a352c80 | |||
| 336c94137f | |||
| 93225b6487 |
@@ -4,13 +4,6 @@
|
||||
**Repository type:** system (kernel).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=module-matrix.yml&actor=0&status=0)
|
||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=release-integration.yml&actor=0&status=0)
|
||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=dependency-audit.yml&actor=0&status=0)
|
||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=security-audit.yml&actor=0&status=0)
|
||||
|
||||
# govoplan-core
|
||||
|
||||
GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, module discovery, migration orchestration, capability contracts, install/uninstall orchestration, and the shared WebUI shell. Platform and feature behavior is supplied by installed modules.
|
||||
|
||||
## Repository ownership
|
||||
@@ -54,7 +47,7 @@ python3 -m venv .venv
|
||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to `tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops`; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to the modules listed by `govoplan_core.settings.Settings.enabled_modules`, including Views when its package is installed; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""add reusable core credential envelopes
|
||||
|
||||
Revision ID: c91f0a72be34
|
||||
Revises: 0f1e2d3c4b5a
|
||||
Create Date: 2026-07-23 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c91f0a72be34"
|
||||
down_revision = "0f1e2d3c4b5a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_credential_envelopes" in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
"core_credential_envelopes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("credential_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("public_data", sa.JSON(), nullable=False),
|
||||
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
|
||||
sa.Column("secret_keys", sa.JSON(), nullable=False),
|
||||
sa.Column("allowed_modules", sa.JSON(), nullable=False),
|
||||
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("revision", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_credential_envelopes_scope",
|
||||
"core_credential_envelopes",
|
||||
["tenant_id", "scope_type", "scope_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_credential_envelopes_active",
|
||||
"core_credential_envelopes",
|
||||
["tenant_id", "is_active", "deleted_at"],
|
||||
unique=False,
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"credential_kind",
|
||||
"is_active",
|
||||
"created_by_user_id",
|
||||
"updated_by_user_id",
|
||||
"deleted_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_core_credential_envelopes_{column}"),
|
||||
"core_credential_envelopes",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_credential_envelopes" in inspector.get_table_names():
|
||||
op.drop_table("core_credential_envelopes")
|
||||
@@ -12,6 +12,7 @@ except ModuleNotFoundError as exc:
|
||||
raise
|
||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core.migrations import migration_metadata_plan
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.server.default_config import get_server_config
|
||||
|
||||
119
alembic/versions/c91f0a72be34_core_credential_envelopes.py
Normal file
119
alembic/versions/c91f0a72be34_core_credential_envelopes.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""add reusable core credential envelopes
|
||||
|
||||
Revision ID: c91f0a72be34
|
||||
Revises: 4f2a9c8e7b6d
|
||||
Create Date: 2026-07-23 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c91f0a72be34"
|
||||
down_revision = "4f2a9c8e7b6d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_credential_envelopes" in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
"core_credential_envelopes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("credential_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("public_data", sa.JSON(), nullable=False),
|
||||
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
|
||||
sa.Column("secret_keys", sa.JSON(), nullable=False),
|
||||
sa.Column("allowed_modules", sa.JSON(), nullable=False),
|
||||
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("revision", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_credential_envelopes_scope",
|
||||
"core_credential_envelopes",
|
||||
["tenant_id", "scope_type", "scope_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_credential_envelopes_active",
|
||||
"core_credential_envelopes",
|
||||
["tenant_id", "is_active", "deleted_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_tenant_id"),
|
||||
"core_credential_envelopes",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_scope_type"),
|
||||
"core_credential_envelopes",
|
||||
["scope_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_scope_id"),
|
||||
"core_credential_envelopes",
|
||||
["scope_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_credential_kind"),
|
||||
"core_credential_envelopes",
|
||||
["credential_kind"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_is_active"),
|
||||
"core_credential_envelopes",
|
||||
["is_active"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_created_by_user_id"),
|
||||
"core_credential_envelopes",
|
||||
["created_by_user_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_updated_by_user_id"),
|
||||
"core_credential_envelopes",
|
||||
["updated_by_user_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_deleted_at"),
|
||||
"core_credential_envelopes",
|
||||
["deleted_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_credential_envelopes" in inspector.get_table_names():
|
||||
op.drop_table("core_credential_envelopes")
|
||||
49
docs/AUTOMATION_CONTRACTS.md
Normal file
49
docs/AUTOMATION_CONTRACTS.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Automation Contracts
|
||||
|
||||
Core defines provider-neutral automation contracts. It does not own domain
|
||||
schedules, Workflow graphs, or Dataflow execution.
|
||||
|
||||
## Invocation Envelope
|
||||
|
||||
`AutomationInvocation` classifies a start as `manual`, `api`, `schedule`,
|
||||
`event`, `workflow`, `dependency`, `retry`, or `backfill`. It carries opaque
|
||||
trigger and delivery references, event identity, correlation and causation
|
||||
IDs, scheduled time, requesting actor, and bounded metadata. Domain runs store
|
||||
this envelope with their immutable definition revision.
|
||||
|
||||
## Current Authorization
|
||||
|
||||
An automated trigger must not persist a user session, bearer token, API key,
|
||||
or a snapshot of all current permissions. It stores:
|
||||
|
||||
- tenant, account, and membership IDs;
|
||||
- an opaque authorization reference;
|
||||
- the minimum scopes required by the pinned definition and output target.
|
||||
|
||||
At delivery time the optional
|
||||
`auth.automationPrincipalProvider` capability resolves current account,
|
||||
membership, role, group, function, and delegation state. It intersects current
|
||||
authorization with the stored grant. Missing, inactive, or reduced
|
||||
authorization blocks the delivery before effects occur.
|
||||
|
||||
## Definition Governance
|
||||
|
||||
The optional `policy.definitionGovernance` capability evaluates `view`,
|
||||
`edit`, `run`, `reuse`, `derive`, and `automate` for system, tenant, group, and
|
||||
user definitions. A decision contains an ordered source path and effective
|
||||
limits. Derived definitions pin their source revision and hash and retain
|
||||
ancestor ceilings. Templates are reusable definitions and cannot run on their
|
||||
own.
|
||||
|
||||
Without Policy, domain modules use a conservative tenant-local fallback:
|
||||
local definitions remain viewable/editable and active complete flows may run;
|
||||
inheritance, reuse, derivation, and automation are unavailable.
|
||||
|
||||
## Delivery Durability
|
||||
|
||||
Domain trigger implementations persist idempotent deliveries before running.
|
||||
The existing `PlatformEvent` bus is process-local and is not a durable
|
||||
automation source. A transactional Core event/outbox bridge is still required
|
||||
to guarantee capture across commits, restarts, and multiple workers. Until
|
||||
that bridge exists, direct event ingestion must be authenticated, tenant
|
||||
scoped, and limited to public or internal event envelopes.
|
||||
@@ -10,12 +10,12 @@ gates. Issues are the active backlog; this document is durable architecture
|
||||
planning context and should be mirrored to the Gitea wiki.
|
||||
|
||||
The meta repository's
|
||||
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||
[Connected Governance Platform Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||
describes the corresponding cross-product stakeholder visions, configurable
|
||||
service and operating configurations, connected outcome stories, and
|
||||
capability horizons. The selected five-stage delivery sequence and its gates
|
||||
are in the meta repository's
|
||||
[Reference Journey Program](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
||||
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
||||
Those product documents are canonical; this Core roadmap remains their
|
||||
technical sequencing and module-routing companion.
|
||||
|
||||
@@ -625,48 +625,48 @@ repositories or to explicit missing-module decisions.
|
||||
|
||||
| Idea | Owner | Tracking |
|
||||
| --- | --- | --- |
|
||||
| Government operations backbone reference model | `govoplan-core` | `add-ideas/govoplan-core#213` |
|
||||
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `add-ideas/govoplan-core#214` |
|
||||
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `add-ideas/govoplan-core#218` |
|
||||
| Access as a module | `govoplan-access` | `add-ideas/govoplan-access#7` |
|
||||
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `add-ideas/govoplan-core#227` |
|
||||
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `add-ideas/govoplan-workflow#1` |
|
||||
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `add-ideas/govoplan-postbox#15`, `add-ideas/govoplan-identity-trust#1` |
|
||||
| Identity, account, function, role, right semantic model | `govoplan-access` | `add-ideas/govoplan-access#9` |
|
||||
| Role-based service directory/catalog | `govoplan-portal` | `add-ideas/govoplan-portal#1` |
|
||||
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `add-ideas/govoplan-tasks#1`, `add-ideas/govoplan-notifications#1` |
|
||||
| OpenProject API / project management connector | `govoplan-connectors` | `add-ideas/govoplan-connectors#1` |
|
||||
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `add-ideas/govoplan-core#196`, `add-ideas/govoplan-connectors#1` |
|
||||
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `add-ideas/govoplan-core#197` |
|
||||
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `add-ideas/govoplan-core#198` |
|
||||
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `add-ideas/govoplan-core#216` |
|
||||
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-templates#1` |
|
||||
| Reporting and BI | `govoplan-reporting`, separate from templates | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-reporting#1` |
|
||||
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `add-ideas/govoplan-files#15` |
|
||||
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `add-ideas/govoplan-core#191`, `add-ideas/govoplan-connectors#2` |
|
||||
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `add-ideas/govoplan-core#215` |
|
||||
| Cases module concept | `govoplan-cases` | `add-ideas/govoplan-core#174` |
|
||||
| Workflow module concept | `govoplan-workflow` | `add-ideas/govoplan-core#175` |
|
||||
| Connectors module concept | `govoplan-connectors` | `add-ideas/govoplan-core#176` |
|
||||
| Adrema-style address and distribution-list management | `govoplan-addresses` | `add-ideas/govoplan-addresses#1` |
|
||||
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `add-ideas/govoplan-connectors#3`, `add-ideas/govoplan-core#198` |
|
||||
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `add-ideas/govoplan-connectors#6` |
|
||||
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-scheduling#1` |
|
||||
| Terminplaner and calendar primitives | `govoplan-calendar` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-calendar#1` |
|
||||
| Terminbuchung appointment booking | `govoplan-appointments` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-appointments#1` |
|
||||
| Collaborative documents | `govoplan-dms` | `add-ideas/govoplan-dms#1` |
|
||||
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `add-ideas/govoplan-core#194`, `add-ideas/govoplan-forms#1` |
|
||||
| RSS consume and emit | `govoplan-connectors` | `add-ideas/govoplan-connectors#4` |
|
||||
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `add-ideas/govoplan-idm#1` |
|
||||
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `add-ideas/govoplan-core#195`, `add-ideas/govoplan-connectors#5` |
|
||||
| Open-Xchange mail/groupware | `govoplan-mail` | `add-ideas/govoplan-mail#5` |
|
||||
| Open-Xchange calendar | `govoplan-calendar` | `add-ideas/govoplan-calendar#2` |
|
||||
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#217` |
|
||||
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#219` |
|
||||
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `add-ideas/govoplan-core#220` |
|
||||
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#19` |
|
||||
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#26` |
|
||||
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#28` |
|
||||
| Government operations backbone reference model | `govoplan-core` | `GovOPlaN/govoplan-core#213` |
|
||||
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `GovOPlaN/govoplan-core#214` |
|
||||
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `GovOPlaN/govoplan-core#218` |
|
||||
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
|
||||
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `GovOPlaN/govoplan-core#227` |
|
||||
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `GovOPlaN/govoplan-workflow#1` |
|
||||
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `GovOPlaN/govoplan-postbox#15`, `GovOPlaN/govoplan-identity-trust#1` |
|
||||
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
|
||||
| Role-based service directory/catalog | `govoplan-portal` | `GovOPlaN/govoplan-portal#1` |
|
||||
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `GovOPlaN/govoplan-tasks#1`, `GovOPlaN/govoplan-notifications#1` |
|
||||
| OpenProject API / project management connector | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#1` |
|
||||
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `GovOPlaN/govoplan-core#196`, `GovOPlaN/govoplan-connectors#1` |
|
||||
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `GovOPlaN/govoplan-core#197` |
|
||||
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `GovOPlaN/govoplan-core#198` |
|
||||
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `GovOPlaN/govoplan-core#216` |
|
||||
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-templates#1` |
|
||||
| Reporting and BI | `govoplan-reporting`, separate from templates | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-reporting#1` |
|
||||
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/govoplan-files#15` |
|
||||
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `GovOPlaN/govoplan-core#191`, `GovOPlaN/govoplan-connectors#2` |
|
||||
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
|
||||
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
|
||||
| Workflow module concept | `govoplan-workflow` | `GovOPlaN/govoplan-core#175` |
|
||||
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
|
||||
| Adrema-style address and distribution-list management | `govoplan-addresses` | `GovOPlaN/govoplan-addresses#1` |
|
||||
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-core#198` |
|
||||
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#6` |
|
||||
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-scheduling#1` |
|
||||
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
|
||||
| Terminbuchung appointment booking | `govoplan-appointments` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-appointments#1` |
|
||||
| Collaborative documents | `govoplan-dms` | `GovOPlaN/govoplan-dms#1` |
|
||||
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `GovOPlaN/govoplan-core#194`, `GovOPlaN/govoplan-forms#1` |
|
||||
| RSS consume and emit | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#4` |
|
||||
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `GovOPlaN/govoplan-idm#1` |
|
||||
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `GovOPlaN/govoplan-core#195`, `GovOPlaN/govoplan-connectors#5` |
|
||||
| Open-Xchange mail/groupware | `govoplan-mail` | `GovOPlaN/govoplan-mail#5` |
|
||||
| Open-Xchange calendar | `govoplan-calendar` | `GovOPlaN/govoplan-calendar#2` |
|
||||
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#217` |
|
||||
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#219` |
|
||||
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `GovOPlaN/govoplan-core#220` |
|
||||
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#19` |
|
||||
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#26` |
|
||||
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#28` |
|
||||
|
||||
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
|
||||
|
||||
@@ -705,7 +705,7 @@ Release composition and tag-only repository handling are documented in
|
||||
## Next Practical Work
|
||||
|
||||
The active cross-product story is
|
||||
[`add-ideas/govoplan#14`](https://git.add-ideas.de/add-ideas/govoplan/issues/14).
|
||||
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
|
||||
Module repositories own implementation issues; do not clone their state here.
|
||||
|
||||
Immediate issue buckets:
|
||||
|
||||
@@ -13,6 +13,7 @@ consistent while each module still owns its domain rules.
|
||||
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
|
||||
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
||||
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
|
||||
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
|
||||
|
||||
## Policy Decision
|
||||
|
||||
@@ -111,6 +112,20 @@ matching compatibility DTOs only for its legacy admin surface. Admin overview
|
||||
responses remain module-local because the same counters are exposed from
|
||||
different menu contexts and are not yet a separately versioned platform API.
|
||||
|
||||
## Definition Governance
|
||||
|
||||
Dataflow and Workflow submit a `DefinitionGovernanceRequest` using only stable
|
||||
scope, principal, status, definition-kind, and limit fields. Policy returns a
|
||||
standard `PolicyDecision`. System definitions may be inherited as read-only;
|
||||
group and user definitions are visible only in matching contexts. Templates
|
||||
may be viewed and derived but never run or automated. A derived definition
|
||||
passes its pinned ancestor limits back through the request context, and Policy
|
||||
applies those limits as ceilings rather than defaults that can be broadened.
|
||||
|
||||
When the capability is absent, modules must not silently emulate cross-scope
|
||||
inheritance. Their conservative fallback is limited to local tenant
|
||||
definitions and disables reuse, derivation, and automation.
|
||||
|
||||
## Frontend Contract
|
||||
|
||||
Policy UIs must:
|
||||
|
||||
@@ -40,26 +40,26 @@ cd /mnt/DATA/git/govoplan
|
||||
Update those refs when cutting a release:
|
||||
|
||||
```text
|
||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
|
||||
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
|
||||
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
|
||||
govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
|
||||
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
|
||||
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
|
||||
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
|
||||
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
|
||||
govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
|
||||
govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
|
||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
|
||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.8
|
||||
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.8
|
||||
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.8
|
||||
govoplan-poll git@git.add-ideas.de:add-ideas/govoplan-poll.git v0.1.8
|
||||
govoplan-scheduling git@git.add-ideas.de:add-ideas/govoplan-scheduling.git v0.1.8
|
||||
govoplan-notifications git@git.add-ideas.de:add-ideas/govoplan-notifications.git v0.1.8
|
||||
govoplan-evaluation git@git.add-ideas.de:add-ideas/govoplan-evaluation.git v0.1.8
|
||||
govoplan-docs git@git.add-ideas.de:add-ideas/govoplan-docs.git v0.1.8
|
||||
govoplan-ops git@git.add-ideas.de:add-ideas/govoplan-ops.git v0.1.8
|
||||
govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
|
||||
govoplan-organizations git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git v0.1.8
|
||||
govoplan-identity git@git.add-ideas.de:GovOPlaN/govoplan-identity.git v0.1.8
|
||||
govoplan-idm git@git.add-ideas.de:GovOPlaN/govoplan-idm.git v0.1.8
|
||||
govoplan-access git@git.add-ideas.de:GovOPlaN/govoplan-access.git v0.1.8
|
||||
govoplan-admin git@git.add-ideas.de:GovOPlaN/govoplan-admin.git v0.1.8
|
||||
govoplan-policy git@git.add-ideas.de:GovOPlaN/govoplan-policy.git v0.1.8
|
||||
govoplan-audit git@git.add-ideas.de:GovOPlaN/govoplan-audit.git v0.1.8
|
||||
govoplan-dashboard git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git v0.1.8
|
||||
govoplan-addresses git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git v0.1.8
|
||||
govoplan-files git@git.add-ideas.de:GovOPlaN/govoplan-files.git v0.1.8
|
||||
govoplan-mail git@git.add-ideas.de:GovOPlaN/govoplan-mail.git v0.1.8
|
||||
govoplan-campaign git@git.add-ideas.de:GovOPlaN/govoplan-campaign.git v0.1.8
|
||||
govoplan-calendar git@git.add-ideas.de:GovOPlaN/govoplan-calendar.git v0.1.8
|
||||
govoplan-poll git@git.add-ideas.de:GovOPlaN/govoplan-poll.git v0.1.8
|
||||
govoplan-scheduling git@git.add-ideas.de:GovOPlaN/govoplan-scheduling.git v0.1.8
|
||||
govoplan-notifications git@git.add-ideas.de:GovOPlaN/govoplan-notifications.git v0.1.8
|
||||
govoplan-evaluation git@git.add-ideas.de:GovOPlaN/govoplan-evaluation.git v0.1.8
|
||||
govoplan-docs git@git.add-ideas.de:GovOPlaN/govoplan-docs.git v0.1.8
|
||||
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
|
||||
```
|
||||
|
||||
## WebUI Packages
|
||||
@@ -892,7 +892,7 @@ before that baseline, so pre-v0.1.7 development revisions are not release
|
||||
upgrade targets. Future release-to-release changes must start from a recorded
|
||||
release baseline and add a new release-track step-up instead of replacing prior
|
||||
release shortcuts. The tracking issue is
|
||||
`add-ideas/govoplan-core#223`.
|
||||
`GovOPlaN/govoplan-core#223`.
|
||||
|
||||
## Related Operator Documents
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ binding design reference: future implementation should follow these decisions
|
||||
unless the decision is explicitly revised here and affected screens are updated
|
||||
to match.
|
||||
|
||||
Active tracking issue: `add-ideas/govoplan-core#225`.
|
||||
Active tracking issue: `GovOPlaN/govoplan-core#225`.
|
||||
|
||||
## Operating Rule
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
||||
"publisher": "ADD ideas",
|
||||
"category": "workflow",
|
||||
"artifact_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
||||
"artifact_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
||||
"artifact_sha256": "<sha256>",
|
||||
"required_modules": [
|
||||
{ "module_id": "portal", "version": ">=0.1.0" },
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
"version": "0.1.4",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-files",
|
||||
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
||||
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||
"webui_package": "@govoplan/files-webui",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||
"migration_safety": "forward_only",
|
||||
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||
"migration_after": ["access"],
|
||||
@@ -54,14 +54,14 @@
|
||||
],
|
||||
"artifact_integrity": {
|
||||
"python": {
|
||||
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
||||
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||
"sha256": "<sha256 of the resolved Python artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
|
||||
"git_ref": "refs/tags/v0.1.4"
|
||||
},
|
||||
"webui": {
|
||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
||||
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
|
||||
@@ -78,9 +78,9 @@
|
||||
"version": "0.1.4",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-mail",
|
||||
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
||||
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||
"webui_package": "@govoplan/mail-webui",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||
"provides_interfaces": [
|
||||
{
|
||||
"name": "mail.campaign_delivery",
|
||||
@@ -97,14 +97,14 @@
|
||||
],
|
||||
"artifact_integrity": {
|
||||
"python": {
|
||||
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
||||
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||
"sha256": "<sha256 of the resolved Python artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
|
||||
"git_ref": "refs/tags/v0.1.4"
|
||||
},
|
||||
"webui": {
|
||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
||||
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
|
||||
@@ -121,19 +121,19 @@
|
||||
"version": "0.1.6",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-dashboard",
|
||||
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
||||
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||
"webui_package": "@govoplan/dashboard-webui",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||
"artifact_integrity": {
|
||||
"python": {
|
||||
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
||||
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||
"sha256": "<sha256 of the resolved Python artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
|
||||
"git_ref": "refs/tags/v0.1.6"
|
||||
},
|
||||
"webui": {
|
||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
||||
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
|
||||
|
||||
@@ -4,6 +4,10 @@ from celery import Celery
|
||||
|
||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
||||
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
|
||||
from govoplan_core.core.dataflows import (
|
||||
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
|
||||
DataflowTriggerDispatcher,
|
||||
)
|
||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
||||
@@ -29,6 +33,7 @@ celery.conf.update(
|
||||
"govoplan.notifications.deliver": {"queue": "notifications"},
|
||||
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
||||
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
|
||||
"govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"},
|
||||
},
|
||||
worker_prefetch_multiplier=1,
|
||||
task_acks_late=True,
|
||||
@@ -39,6 +44,11 @@ celery.conf.update(
|
||||
"schedule": 60.0,
|
||||
"args": (None, 100),
|
||||
},
|
||||
"dataflow-triggers-every-minute": {
|
||||
"task": "govoplan.dataflow.dispatch_triggers",
|
||||
"schedule": 60.0,
|
||||
"args": (100,),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -86,6 +96,18 @@ def _calendar_outbox() -> CalendarOutboxProvider | None:
|
||||
return capability
|
||||
|
||||
|
||||
def _dataflow_trigger_dispatcher() -> DataflowTriggerDispatcher | None:
|
||||
registry = _platform_registry()
|
||||
if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER):
|
||||
return None
|
||||
capability = registry.require_capability(
|
||||
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER
|
||||
)
|
||||
if not isinstance(capability, DataflowTriggerDispatcher):
|
||||
raise RuntimeError("Dataflow trigger dispatcher capability is invalid")
|
||||
return capability
|
||||
|
||||
|
||||
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
|
||||
def send_email(self, job_id: str):
|
||||
"""Send one explicitly queued campaign job.
|
||||
@@ -149,3 +171,29 @@ def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50
|
||||
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="govoplan.dataflow.dispatch_triggers",
|
||||
bind=True,
|
||||
max_retries=0,
|
||||
)
|
||||
def dispatch_dataflow_triggers(self, limit: int = 100):
|
||||
"""Drain durable Dataflow trigger deliveries and due schedules."""
|
||||
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().SessionLocal() as session:
|
||||
provider = _dataflow_trigger_dispatcher()
|
||||
if provider is None:
|
||||
return {
|
||||
"queued": 0,
|
||||
"processed": 0,
|
||||
"succeeded": 0,
|
||||
"failed": 0,
|
||||
"blocked": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
result = dict(provider.dispatch_due(session, limit=limit))
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
@@ -28,6 +28,9 @@ CAPABILITY_AUDIT_RECORDER = "audit.recorder"
|
||||
CAPABILITY_AUDIT_RETENTION = "audit.retention"
|
||||
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER = (
|
||||
"auth.automationPrincipalProvider"
|
||||
)
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
||||
@@ -48,6 +51,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
CAPABILITY_AUDIT_RECORDER,
|
||||
CAPABILITY_AUDIT_RETENTION,
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
@@ -57,6 +61,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
AUTH_CAPABILITY_NAMES = frozenset(
|
||||
{
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
|
||||
98
src/govoplan_core/core/automation.py
Normal file
98
src/govoplan_core/core/automation.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
)
|
||||
|
||||
AutomationInvocationKind = Literal[
|
||||
"manual",
|
||||
"api",
|
||||
"schedule",
|
||||
"event",
|
||||
"workflow",
|
||||
"dependency",
|
||||
"retry",
|
||||
"backfill",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AutomationInvocation:
|
||||
kind: AutomationInvocationKind = "manual"
|
||||
trigger_ref: str | None = None
|
||||
delivery_ref: str | None = None
|
||||
event_id: str | None = None
|
||||
event_type: str | None = None
|
||||
correlation_id: str | None = None
|
||||
causation_id: str | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
requested_by: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AutomationPrincipalRequest:
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
membership_id: str
|
||||
authorization_ref: str
|
||||
grant_scopes: tuple[str, ...]
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AutomationPrincipalResolution:
|
||||
allowed: bool
|
||||
principal: object | None = None
|
||||
reason: str | None = None
|
||||
granted_scopes: tuple[str, ...] = ()
|
||||
missing_scopes: tuple[str, ...] = ()
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AutomationPrincipalProvider(Protocol):
|
||||
def resolve_automation_principal(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: AutomationPrincipalRequest,
|
||||
) -> AutomationPrincipalResolution:
|
||||
...
|
||||
|
||||
|
||||
def automation_principal_provider(
|
||||
registry: object | None,
|
||||
) -> AutomationPrincipalProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||
)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||
)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, AutomationPrincipalProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
|
||||
"AutomationInvocation",
|
||||
"AutomationInvocationKind",
|
||||
"AutomationPrincipalProvider",
|
||||
"AutomationPrincipalRequest",
|
||||
"AutomationPrincipalResolution",
|
||||
"automation_principal_provider",
|
||||
]
|
||||
172
src/govoplan_core/core/dataflows.py
Normal file
172
src/govoplan_core/core/dataflows.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.automation import AutomationInvocation
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
|
||||
|
||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle"
|
||||
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher"
|
||||
|
||||
|
||||
class DataflowRunError(ValueError):
|
||||
"""Stable base error for module-neutral Dataflow run operations."""
|
||||
|
||||
|
||||
class DataflowRunNotFoundError(DataflowRunError):
|
||||
pass
|
||||
|
||||
|
||||
class DataflowRunConflictError(DataflowRunError):
|
||||
pass
|
||||
|
||||
|
||||
class DataflowRunUnavailableError(DataflowRunError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DataflowPublicationTarget:
|
||||
target_datasource_ref: str | None = None
|
||||
name: str | None = None
|
||||
source_name: str | None = None
|
||||
description: str | None = None
|
||||
freeze: bool = False
|
||||
frozen_label: str | None = None
|
||||
set_current: bool = True
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DataflowRunRequest:
|
||||
pipeline_ref: str
|
||||
revision: int
|
||||
idempotency_key: str
|
||||
row_limit: int = 500
|
||||
publication: DataflowPublicationTarget | None = None
|
||||
invocation: AutomationInvocation = field(
|
||||
default_factory=AutomationInvocation
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DataflowRunDescriptor:
|
||||
ref: str
|
||||
pipeline_ref: str
|
||||
revision: int
|
||||
status: str
|
||||
definition_hash: str
|
||||
executor_version: str
|
||||
input_row_count: int = 0
|
||||
output_row_count: int = 0
|
||||
output_publication_ref: str | None = None
|
||||
output_datasource_ref: str | None = None
|
||||
output_materialization_ref: str | None = None
|
||||
invocation_kind: str = "manual"
|
||||
trigger_ref: str | None = None
|
||||
delivery_ref: str | None = None
|
||||
error: str | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
replayed: bool = False
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DataflowRunLifecycleProvider(Protocol):
|
||||
def start_run(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DataflowRunRequest,
|
||||
) -> DataflowRunDescriptor:
|
||||
...
|
||||
|
||||
def get_run(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
run_ref: str,
|
||||
) -> DataflowRunDescriptor | None:
|
||||
...
|
||||
|
||||
def cancel_run(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
run_ref: str,
|
||||
) -> DataflowRunDescriptor:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DataflowTriggerDispatcher(Protocol):
|
||||
def dispatch_due(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def ingest_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event: PlatformEvent,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
def dataflow_run_lifecycle(
|
||||
registry: object | None,
|
||||
) -> DataflowRunLifecycleProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_LIFECYCLE)
|
||||
return capability if isinstance(capability, DataflowRunLifecycleProvider) else None
|
||||
|
||||
|
||||
def dataflow_trigger_dispatcher(
|
||||
registry: object | None,
|
||||
) -> DataflowTriggerDispatcher | None:
|
||||
capability = _capability(registry, CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, DataflowTriggerDispatcher)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_DATAFLOW_RUN_LIFECYCLE",
|
||||
"CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER",
|
||||
"DataflowPublicationTarget",
|
||||
"DataflowRunConflictError",
|
||||
"DataflowRunDescriptor",
|
||||
"DataflowRunError",
|
||||
"DataflowRunLifecycleProvider",
|
||||
"DataflowRunNotFoundError",
|
||||
"DataflowRunRequest",
|
||||
"DataflowRunUnavailableError",
|
||||
"DataflowTriggerDispatcher",
|
||||
"dataflow_run_lifecycle",
|
||||
"dataflow_trigger_dispatcher",
|
||||
]
|
||||
438
src/govoplan_core/core/datasources.py
Normal file
438
src/govoplan_core/core/datasources.py
Normal file
@@ -0,0 +1,438 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
|
||||
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
|
||||
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
|
||||
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
|
||||
|
||||
DatasourceMode = Literal["live", "cached", "static"]
|
||||
DatasourceKind = Literal[
|
||||
"upload",
|
||||
"database",
|
||||
"http",
|
||||
"rest",
|
||||
"directory",
|
||||
"file",
|
||||
"feed",
|
||||
"custom",
|
||||
]
|
||||
DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"]
|
||||
DatasourceConsistency = Literal["current", "live", "frozen"]
|
||||
|
||||
|
||||
class DatasourceError(ValueError):
|
||||
"""Stable base error for provider-neutral datasource operations."""
|
||||
|
||||
|
||||
class DatasourceNotFoundError(DatasourceError):
|
||||
pass
|
||||
|
||||
|
||||
class DatasourceAccessError(DatasourceError):
|
||||
pass
|
||||
|
||||
|
||||
class DatasourceValidationError(DatasourceError):
|
||||
pass
|
||||
|
||||
|
||||
class DatasourceUnavailableError(DatasourceError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceField:
|
||||
name: str
|
||||
data_type: str
|
||||
nullable: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceDescriptor:
|
||||
ref: str
|
||||
source_name: str
|
||||
name: str
|
||||
kind: DatasourceKind
|
||||
mode: DatasourceMode
|
||||
shape: DatasourceShape
|
||||
status: str = "active"
|
||||
description: str | None = None
|
||||
provider: str | None = None
|
||||
provider_ref: str | None = None
|
||||
schema: tuple[DatasourceField, ...] = ()
|
||||
schema_version: str = "1"
|
||||
fingerprint: str = ""
|
||||
current_materialization_ref: str | None = None
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
updated_at: datetime | None = None
|
||||
capabilities: tuple[str, ...] = ("read",)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceMaterialization:
|
||||
ref: str
|
||||
datasource_ref: str
|
||||
revision: int
|
||||
state: str
|
||||
fingerprint: str
|
||||
schema: tuple[DatasourceField, ...] = ()
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
frozen_at: datetime | None = None
|
||||
frozen_label: str | None = None
|
||||
source_timestamp: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceStage:
|
||||
ref: str
|
||||
name: str
|
||||
source_name: str
|
||||
kind: DatasourceKind
|
||||
mode: DatasourceMode
|
||||
shape: DatasourceShape
|
||||
state: str
|
||||
target_datasource_ref: str | None = None
|
||||
fingerprint: str = ""
|
||||
schema: tuple[DatasourceField, ...] = ()
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
validation: Mapping[str, object] = field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
promoted_at: datetime | None = None
|
||||
promoted_materialization_ref: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceReadRequest:
|
||||
datasource_ref: str
|
||||
materialization_ref: str | None = None
|
||||
consistency: DatasourceConsistency = "current"
|
||||
limit: int = 250
|
||||
offset: int = 0
|
||||
columns: tuple[str, ...] = ()
|
||||
expected_fingerprint: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceReadResult:
|
||||
datasource: DatasourceDescriptor
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
total_rows: int
|
||||
truncated: bool
|
||||
materialization: DatasourceMaterialization | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceStageInput:
|
||||
name: str
|
||||
source_name: str
|
||||
kind: DatasourceKind
|
||||
mode: DatasourceMode
|
||||
shape: DatasourceShape
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
description: str | None = None
|
||||
target_datasource_ref: str | None = None
|
||||
provider: str | None = None
|
||||
provider_ref: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourcePublicationRequest:
|
||||
producer_module: str
|
||||
producer_run_ref: str
|
||||
idempotency_key: str
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
target_datasource_ref: str | None = None
|
||||
name: str | None = None
|
||||
source_name: str | None = None
|
||||
description: str | None = None
|
||||
freeze: bool = False
|
||||
frozen_label: str | None = None
|
||||
set_current: bool = True
|
||||
source_timestamp: datetime | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourcePublicationResult:
|
||||
ref: str
|
||||
status: str
|
||||
datasource: DatasourceDescriptor
|
||||
materialization: DatasourceMaterialization
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceOrigin:
|
||||
ref: str
|
||||
source_name: str
|
||||
name: str
|
||||
kind: DatasourceKind
|
||||
shape: DatasourceShape
|
||||
supported_modes: tuple[DatasourceMode, ...]
|
||||
provider: str
|
||||
description: str | None = None
|
||||
schema: tuple[DatasourceField, ...] = ()
|
||||
schema_version: str = "1"
|
||||
fingerprint: str = ""
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
updated_at: datetime | None = None
|
||||
capabilities: tuple[str, ...] = ("read",)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceOriginReadRequest:
|
||||
origin_ref: str
|
||||
limit: int = 250
|
||||
offset: int = 0
|
||||
columns: tuple[str, ...] = ()
|
||||
expected_fingerprint: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceOriginReadResult:
|
||||
origin: DatasourceOrigin
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
total_rows: int
|
||||
truncated: bool
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DatasourceCatalogueProvider(Protocol):
|
||||
def list_datasources(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
) -> Sequence[DatasourceDescriptor]:
|
||||
...
|
||||
|
||||
def get_datasource(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
datasource_ref: str,
|
||||
) -> DatasourceDescriptor | None:
|
||||
...
|
||||
|
||||
def read_datasource(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DatasourceReadRequest,
|
||||
) -> DatasourceReadResult:
|
||||
...
|
||||
|
||||
def list_materializations(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
datasource_ref: str,
|
||||
) -> Sequence[DatasourceMaterialization]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DatasourceLifecycleProvider(Protocol):
|
||||
def list_stages(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
limit: int = 100,
|
||||
) -> Sequence[DatasourceStage]:
|
||||
...
|
||||
|
||||
def create_stage(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
stage: DatasourceStageInput,
|
||||
) -> DatasourceStage:
|
||||
...
|
||||
|
||||
def promote_stage(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
stage_ref: str,
|
||||
freeze: bool = False,
|
||||
frozen_label: str | None = None,
|
||||
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
|
||||
...
|
||||
|
||||
def register_origin(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
origin_ref: str,
|
||||
name: str,
|
||||
source_name: str,
|
||||
mode: DatasourceMode,
|
||||
description: str | None = None,
|
||||
) -> DatasourceDescriptor:
|
||||
...
|
||||
|
||||
def refresh_datasource(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
datasource_ref: str,
|
||||
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
|
||||
...
|
||||
|
||||
def freeze_datasource(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
datasource_ref: str,
|
||||
label: str | None = None,
|
||||
) -> DatasourceMaterialization:
|
||||
...
|
||||
|
||||
def retire_datasource(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
datasource_ref: str,
|
||||
) -> DatasourceDescriptor:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DatasourcePublicationProvider(Protocol):
|
||||
def publish_rows(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DatasourcePublicationRequest,
|
||||
) -> DatasourcePublicationResult:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DatasourceOriginProvider(Protocol):
|
||||
def list_origins(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
) -> Sequence[DatasourceOrigin]:
|
||||
...
|
||||
|
||||
def get_origin(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
origin_ref: str,
|
||||
) -> DatasourceOrigin | None:
|
||||
...
|
||||
|
||||
def read_origin(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DatasourceOriginReadRequest,
|
||||
) -> DatasourceOriginReadResult:
|
||||
...
|
||||
|
||||
|
||||
def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATASOURCE_CATALOGUE)
|
||||
return capability if isinstance(capability, DatasourceCatalogueProvider) else None
|
||||
|
||||
|
||||
def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE)
|
||||
return capability if isinstance(capability, DatasourceLifecycleProvider) else None
|
||||
|
||||
|
||||
def datasource_publication(
|
||||
registry: object | None,
|
||||
) -> DatasourcePublicationProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATASOURCE_PUBLICATION)
|
||||
return capability if isinstance(capability, DatasourcePublicationProvider) else None
|
||||
|
||||
|
||||
def datasource_origins(registry: object | None) -> DatasourceOriginProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATASOURCE_ORIGINS)
|
||||
return capability if isinstance(capability, DatasourceOriginProvider) else None
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_DATASOURCE_CATALOGUE",
|
||||
"CAPABILITY_DATASOURCE_LIFECYCLE",
|
||||
"CAPABILITY_DATASOURCE_ORIGINS",
|
||||
"DatasourceAccessError",
|
||||
"DatasourceCatalogueProvider",
|
||||
"DatasourceConsistency",
|
||||
"DatasourceDescriptor",
|
||||
"DatasourceError",
|
||||
"DatasourceField",
|
||||
"DatasourceKind",
|
||||
"DatasourceLifecycleProvider",
|
||||
"DatasourceMaterialization",
|
||||
"DatasourceMode",
|
||||
"DatasourceNotFoundError",
|
||||
"DatasourceOrigin",
|
||||
"DatasourceOriginProvider",
|
||||
"DatasourceOriginReadRequest",
|
||||
"DatasourceOriginReadResult",
|
||||
"DatasourceReadRequest",
|
||||
"DatasourceReadResult",
|
||||
"DatasourceShape",
|
||||
"DatasourceStage",
|
||||
"DatasourceStageInput",
|
||||
"DatasourceUnavailableError",
|
||||
"DatasourceValidationError",
|
||||
"datasource_catalogue",
|
||||
"datasource_lifecycle",
|
||||
"datasource_origins",
|
||||
]
|
||||
342
src/govoplan_core/core/definition_graphs.py
Normal file
342
src/govoplan_core/core/definition_graphs.py
Normal file
@@ -0,0 +1,342 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionPort:
|
||||
id: str
|
||||
label: str
|
||||
required: bool = True
|
||||
multiple: bool = False
|
||||
minimum_connections: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionConfigField:
|
||||
id: str
|
||||
label: str
|
||||
kind: str
|
||||
required: bool = False
|
||||
description: str | None = None
|
||||
options: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionNodeType:
|
||||
type: str
|
||||
category: str
|
||||
label: str
|
||||
description: str
|
||||
icon: str
|
||||
input_ports: tuple[DefinitionPort, ...] = ()
|
||||
output_ports: tuple[DefinitionPort, ...] = (
|
||||
DefinitionPort(id="output", label="Output"),
|
||||
)
|
||||
config_fields: tuple[DefinitionConfigField, ...] = ()
|
||||
default_config: Mapping[str, Any] = field(default_factory=dict)
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionNode:
|
||||
id: str
|
||||
type: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionEdge:
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
source_port: str = "output"
|
||||
target_port: str = "input"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionNodeCountConstraint:
|
||||
code: str
|
||||
label: str
|
||||
minimum: int = 0
|
||||
maximum: int | None = None
|
||||
node_types: tuple[str, ...] = ()
|
||||
type_prefixes: tuple[str, ...] = ()
|
||||
categories: tuple[str, ...] = ()
|
||||
|
||||
def matches(self, node: DefinitionNode, node_type: DefinitionNodeType | None) -> bool:
|
||||
return (
|
||||
node.type in self.node_types
|
||||
or any(node.type.startswith(prefix) for prefix in self.type_prefixes)
|
||||
or node_type is not None
|
||||
and node_type.category in self.categories
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionGraphConstraints:
|
||||
max_nodes: int = 100
|
||||
max_edges: int = 200
|
||||
allow_cycles: bool = False
|
||||
require_connected: bool = True
|
||||
node_counts: tuple[DefinitionNodeCountConstraint, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionGraphLibrary:
|
||||
id: str
|
||||
version: str
|
||||
category_labels: Mapping[str, str]
|
||||
node_types: tuple[DefinitionNodeType, ...]
|
||||
constraints: DefinitionGraphConstraints = field(default_factory=DefinitionGraphConstraints)
|
||||
|
||||
def node_type(self, type_id: str) -> DefinitionNodeType | None:
|
||||
return next((item for item in self.node_types if item.type == type_id), None)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionDiagnostic:
|
||||
severity: str
|
||||
code: str
|
||||
message: str
|
||||
node_id: str | None = None
|
||||
field: str | None = None
|
||||
|
||||
|
||||
def validate_definition_graph(
|
||||
library: DefinitionGraphLibrary,
|
||||
*,
|
||||
nodes: Sequence[DefinitionNode],
|
||||
edges: Sequence[DefinitionEdge],
|
||||
) -> tuple[DefinitionDiagnostic, ...]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
constraints = library.constraints
|
||||
if not nodes:
|
||||
return (_error("graph.empty", "Add nodes before saving the definition."),)
|
||||
if len(nodes) > constraints.max_nodes:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"graph.node_limit",
|
||||
f"Definitions are limited to {constraints.max_nodes:,} nodes.",
|
||||
)
|
||||
)
|
||||
if len(edges) > constraints.max_edges:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"graph.edge_limit",
|
||||
f"Definitions are limited to {constraints.max_edges:,} edges.",
|
||||
)
|
||||
)
|
||||
|
||||
node_by_id = {node.id: node for node in nodes}
|
||||
if len(node_by_id) != len(nodes):
|
||||
diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
|
||||
if len({edge.id for edge in edges}) != len(edges):
|
||||
diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
|
||||
|
||||
incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
|
||||
undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
|
||||
topology_edges: list[DefinitionEdge] = []
|
||||
for edge in edges:
|
||||
if edge.source not in node_by_id:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"edge.unknown_source",
|
||||
f"Edge {edge.id!r} references an unknown source node.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if edge.target not in node_by_id:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"edge.unknown_target",
|
||||
f"Edge {edge.id!r} references an unknown target node.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if edge.source == edge.target:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"edge.self_reference",
|
||||
"A node cannot connect to itself.",
|
||||
node_id=edge.source,
|
||||
)
|
||||
)
|
||||
continue
|
||||
topology_edges.append(edge)
|
||||
source_definition = library.node_type(node_by_id[edge.source].type)
|
||||
target_definition = library.node_type(node_by_id[edge.target].type)
|
||||
if source_definition is not None and edge.source_port not in {
|
||||
port.id for port in source_definition.output_ports
|
||||
}:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"edge.unknown_source_port",
|
||||
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
|
||||
node_id=edge.source,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if target_definition is not None and edge.target_port not in {
|
||||
port.id for port in target_definition.input_ports
|
||||
}:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"edge.unknown_target_port",
|
||||
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
|
||||
node_id=edge.target,
|
||||
)
|
||||
)
|
||||
continue
|
||||
incoming[edge.target].append(edge)
|
||||
undirected[edge.source].add(edge.target)
|
||||
undirected[edge.target].add(edge.source)
|
||||
|
||||
for node in nodes:
|
||||
definition = library.node_type(node.type)
|
||||
if definition is None:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"node.unsupported_type",
|
||||
f"Node type {node.type!r} is not in the {library.id!r} library.",
|
||||
node_id=node.id,
|
||||
field="type",
|
||||
)
|
||||
)
|
||||
continue
|
||||
for port in definition.input_ports:
|
||||
connections = [
|
||||
edge for edge in incoming.get(node.id, ()) if edge.target_port == port.id
|
||||
]
|
||||
minimum = port.minimum_connections if port.required else 0
|
||||
if len(connections) < minimum:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"node.input_required",
|
||||
(
|
||||
f"{definition.label} requires {minimum} "
|
||||
f"{port.label.lower()} connection"
|
||||
f"{'' if minimum == 1 else 's'}."
|
||||
),
|
||||
node_id=node.id,
|
||||
)
|
||||
)
|
||||
if not port.multiple and len(connections) > 1:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"node.input_multiple",
|
||||
f"{port.label} accepts only one connection.",
|
||||
node_id=node.id,
|
||||
)
|
||||
)
|
||||
|
||||
for count_constraint in constraints.node_counts:
|
||||
count = sum(
|
||||
count_constraint.matches(node, library.node_type(node.type))
|
||||
for node in nodes
|
||||
)
|
||||
if count < count_constraint.minimum:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
count_constraint.code,
|
||||
(
|
||||
f"A definition needs at least {count_constraint.minimum} "
|
||||
f"{count_constraint.label} node"
|
||||
f"{'' if count_constraint.minimum == 1 else 's'}."
|
||||
),
|
||||
)
|
||||
)
|
||||
if count_constraint.maximum is not None and count > count_constraint.maximum:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
count_constraint.code,
|
||||
(
|
||||
f"A definition allows at most {count_constraint.maximum} "
|
||||
f"{count_constraint.label} node"
|
||||
f"{'' if count_constraint.maximum == 1 else 's'}."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
_, cyclic = definition_topological_order(nodes, topology_edges)
|
||||
if cyclic and not constraints.allow_cycles:
|
||||
diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
|
||||
if constraints.require_connected and len(node_by_id) > 1:
|
||||
connected = _connected_nodes(next(iter(node_by_id)), undirected)
|
||||
if len(connected) != len(node_by_id):
|
||||
diagnostics.append(
|
||||
_error("graph.disconnected", "Every node must belong to one connected definition.")
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
|
||||
|
||||
def definition_topological_order(
|
||||
nodes: Sequence[DefinitionNode],
|
||||
edges: Sequence[DefinitionEdge],
|
||||
) -> tuple[tuple[str, ...], bool]:
|
||||
node_ids = [node.id for node in nodes]
|
||||
incoming_count = {node_id: 0 for node_id in node_ids}
|
||||
outgoing: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
|
||||
for edge in edges:
|
||||
if (
|
||||
edge.source in outgoing
|
||||
and edge.target in incoming_count
|
||||
and edge.source != edge.target
|
||||
):
|
||||
outgoing[edge.source].append(edge.target)
|
||||
incoming_count[edge.target] += 1
|
||||
ready = deque(node_id for node_id in node_ids if incoming_count[node_id] == 0)
|
||||
ordered: list[str] = []
|
||||
while ready:
|
||||
node_id = ready.popleft()
|
||||
ordered.append(node_id)
|
||||
for target in outgoing[node_id]:
|
||||
incoming_count[target] -= 1
|
||||
if incoming_count[target] == 0:
|
||||
ready.append(target)
|
||||
return tuple(ordered), len(ordered) != len(node_ids)
|
||||
|
||||
|
||||
def _connected_nodes(start: str, adjacency: Mapping[str, set[str]]) -> set[str]:
|
||||
seen: set[str] = set()
|
||||
pending = [start]
|
||||
while pending:
|
||||
node_id = pending.pop()
|
||||
if node_id in seen:
|
||||
continue
|
||||
seen.add(node_id)
|
||||
pending.extend(adjacency.get(node_id, ()))
|
||||
return seen
|
||||
|
||||
|
||||
def _error(
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
node_id: str | None = None,
|
||||
field: str | None = None,
|
||||
) -> DefinitionDiagnostic:
|
||||
return DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code=code,
|
||||
message=message,
|
||||
node_id=node_id,
|
||||
field=field,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DefinitionConfigField",
|
||||
"DefinitionDiagnostic",
|
||||
"DefinitionEdge",
|
||||
"DefinitionGraphConstraints",
|
||||
"DefinitionGraphLibrary",
|
||||
"DefinitionNode",
|
||||
"DefinitionNodeCountConstraint",
|
||||
"DefinitionNodeType",
|
||||
"DefinitionPort",
|
||||
"definition_topological_order",
|
||||
"validate_definition_graph",
|
||||
]
|
||||
@@ -4,6 +4,8 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
|
||||
@@ -57,6 +59,7 @@ class NavItem:
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +71,7 @@ class FrontendRoute:
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -92,6 +96,7 @@ class FrontendModule:
|
||||
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -4,11 +4,24 @@ from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
|
||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
||||
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||
DefinitionKind = Literal["flow", "template"]
|
||||
DefinitionGovernanceAction = Literal[
|
||||
"view",
|
||||
"edit",
|
||||
"run",
|
||||
"reuse",
|
||||
"derive",
|
||||
"automate",
|
||||
]
|
||||
|
||||
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
|
||||
|
||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
||||
|
||||
@@ -128,6 +141,63 @@ class PolicyDecision:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionScopeRef:
|
||||
scope_type: DefinitionScopeType
|
||||
scope_id: str | None = None
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return policy_source_path(self.scope_type, self.scope_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionGovernanceRequest:
|
||||
module_id: str
|
||||
definition_ref: str
|
||||
tenant_id: str
|
||||
definition_scope: DefinitionScopeRef
|
||||
target_scope: DefinitionScopeRef
|
||||
definition_kind: DefinitionKind
|
||||
action: DefinitionGovernanceAction
|
||||
actor: PrincipalRef
|
||||
status: str = "draft"
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_run: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
context: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DefinitionGovernancePolicy(Protocol):
|
||||
def resolve_definition_action(
|
||||
self,
|
||||
*,
|
||||
request: DefinitionGovernanceRequest,
|
||||
) -> PolicyDecision:
|
||||
...
|
||||
|
||||
|
||||
def definition_governance_policy(
|
||||
registry: object | None,
|
||||
) -> DefinitionGovernancePolicy | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE
|
||||
)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, DefinitionGovernancePolicy)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SchedulingParticipantPrivacyRequest:
|
||||
"""Context for resolving what one Scheduling participant may see.
|
||||
|
||||
@@ -25,6 +25,13 @@ from govoplan_core.core.modules import (
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
from govoplan_core.core.views import (
|
||||
ViewSurface,
|
||||
module_view_surface_id,
|
||||
navigation_view_surface_id,
|
||||
route_view_surface_id,
|
||||
validate_view_surface_id,
|
||||
)
|
||||
|
||||
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
||||
@@ -113,6 +120,13 @@ class PlatformRegistry:
|
||||
def nav_items(self) -> tuple[NavItem, ...]:
|
||||
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
|
||||
|
||||
def view_surfaces(self) -> tuple[ViewSurface, ...]:
|
||||
return tuple(
|
||||
surface
|
||||
for manifest in self.manifests()
|
||||
for surface in manifest_view_surfaces(manifest)
|
||||
)
|
||||
|
||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
||||
|
||||
@@ -230,6 +244,53 @@ class PlatformRegistry:
|
||||
return (self._manifests[module_id] for module_id in ordered)
|
||||
|
||||
|
||||
def manifest_view_surfaces(manifest: ModuleManifest) -> tuple[ViewSurface, ...]:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
return ()
|
||||
root_id = module_view_surface_id(manifest.id)
|
||||
surfaces = [
|
||||
ViewSurface(
|
||||
id=root_id,
|
||||
module_id=manifest.id,
|
||||
kind="module",
|
||||
label=manifest.name,
|
||||
order=min((item.order for item in frontend.nav_items), default=100),
|
||||
)
|
||||
]
|
||||
surfaces.extend(
|
||||
ViewSurface(
|
||||
id=item.surface_id or navigation_view_surface_id(manifest.id, item.path),
|
||||
module_id=manifest.id,
|
||||
kind="navigation",
|
||||
label=item.label,
|
||||
parent_id=root_id,
|
||||
order=item.order,
|
||||
)
|
||||
for item in frontend.nav_items
|
||||
)
|
||||
surfaces.extend(
|
||||
ViewSurface(
|
||||
id=route.surface_id or route_view_surface_id(manifest.id, route.path),
|
||||
module_id=manifest.id,
|
||||
kind="route",
|
||||
label=route.component,
|
||||
parent_id=root_id,
|
||||
description=route.path,
|
||||
order=route.order,
|
||||
)
|
||||
for route in (*frontend.routes, *frontend.settings_routes)
|
||||
)
|
||||
surfaces.extend(
|
||||
replace(
|
||||
surface,
|
||||
parent_id=surface.parent_id or root_id,
|
||||
)
|
||||
for surface in frontend.view_surfaces
|
||||
)
|
||||
return tuple(surfaces)
|
||||
|
||||
|
||||
def _normalize_delete_veto_result(
|
||||
result: object,
|
||||
*,
|
||||
@@ -438,8 +499,90 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
if route.surface_id is not None:
|
||||
_validate_view_surface_id(manifest.id, route.surface_id)
|
||||
for item in frontend.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
if item.surface_id is not None:
|
||||
_validate_view_surface_id(manifest.id, item.surface_id)
|
||||
_validate_view_surfaces(manifest)
|
||||
|
||||
|
||||
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
return
|
||||
root_id = module_view_surface_id(manifest.id)
|
||||
_validate_view_surface_id(manifest.id, root_id)
|
||||
known_ids = {root_id}
|
||||
for item in frontend.nav_items:
|
||||
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
|
||||
_validate_view_surface_id(manifest.id, surface_id)
|
||||
if surface_id in known_ids:
|
||||
raise RegistryError(
|
||||
f"Duplicate view surface id {surface_id!r} in module {manifest.id!r}"
|
||||
)
|
||||
known_ids.add(surface_id)
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
|
||||
_validate_view_surface_id(manifest.id, surface_id)
|
||||
if surface_id in known_ids:
|
||||
raise RegistryError(
|
||||
f"Duplicate view surface id {surface_id!r} in module {manifest.id!r}"
|
||||
)
|
||||
known_ids.add(surface_id)
|
||||
for surface in frontend.view_surfaces:
|
||||
if surface.module_id != manifest.id:
|
||||
raise RegistryError(
|
||||
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
|
||||
f"not module {manifest.id!r}"
|
||||
)
|
||||
_validate_view_surface_id(manifest.id, surface.id)
|
||||
if surface.id in known_ids:
|
||||
raise RegistryError(
|
||||
f"Duplicate view surface id {surface.id!r} in module {manifest.id!r}"
|
||||
)
|
||||
known_ids.add(surface.id)
|
||||
for surface in frontend.view_surfaces:
|
||||
parent_id = surface.parent_id or root_id
|
||||
if parent_id not in known_ids:
|
||||
raise RegistryError(
|
||||
f"View surface {surface.id!r} in module {manifest.id!r} "
|
||||
f"references unknown parent {parent_id!r}"
|
||||
)
|
||||
if surface.required and not surface.default_visible:
|
||||
raise RegistryError(
|
||||
f"Required view surface {surface.id!r} in module {manifest.id!r} "
|
||||
"must be visible by default"
|
||||
)
|
||||
parent_by_id = {
|
||||
surface.id: surface.parent_id or root_id
|
||||
for surface in frontend.view_surfaces
|
||||
}
|
||||
for surface_id in parent_by_id:
|
||||
path: set[str] = set()
|
||||
current_id: str | None = surface_id
|
||||
while current_id in parent_by_id:
|
||||
if current_id in path:
|
||||
raise RegistryError(
|
||||
f"View surface hierarchy in module {manifest.id!r} "
|
||||
f"contains a cycle at {current_id!r}"
|
||||
)
|
||||
path.add(current_id)
|
||||
current_id = parent_by_id[current_id]
|
||||
|
||||
|
||||
def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
|
||||
if not validate_view_surface_id(surface_id):
|
||||
raise RegistryError(
|
||||
f"View surface id for module {module_id!r} is invalid: {surface_id!r}"
|
||||
)
|
||||
if not surface_id.startswith(f"{module_id}."):
|
||||
raise RegistryError(
|
||||
f"View surface id for module {module_id!r} must start with "
|
||||
f"{module_id!r} followed by '.': {surface_id!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||
|
||||
230
src/govoplan_core/core/tabular_sources.py
Normal file
230
src/govoplan_core/core/tabular_sources.py
Normal file
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_CONNECTORS_TABULAR_SOURCES = "connectors.tabularSources"
|
||||
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER = "connectors.tabularSnapshotWriter"
|
||||
|
||||
|
||||
class TabularSourceError(ValueError):
|
||||
"""Stable base error for provider-neutral tabular source operations."""
|
||||
|
||||
|
||||
class TabularSourceNotFoundError(TabularSourceError):
|
||||
pass
|
||||
|
||||
|
||||
class TabularSourceAccessError(TabularSourceError):
|
||||
pass
|
||||
|
||||
|
||||
class TabularSourceValidationError(TabularSourceError):
|
||||
pass
|
||||
|
||||
|
||||
def parse_tabular_csv(
|
||||
csv_text: str,
|
||||
*,
|
||||
delimiter: str = ",",
|
||||
max_rows: int = 10_000,
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
"""Parse a bounded CSV document into JSON-compatible tabular rows."""
|
||||
|
||||
if len(delimiter) != 1:
|
||||
raise TabularSourceValidationError("CSV delimiter must be one character.")
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
|
||||
if not reader.fieldnames or any(not str(name or "").strip() for name in reader.fieldnames):
|
||||
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||
normalized_headers = [str(name).strip() for name in reader.fieldnames]
|
||||
normalized_headers[0] = normalized_headers[0].lstrip("\ufeff")
|
||||
if any(not name for name in normalized_headers):
|
||||
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||
if len(set(normalized_headers)) != len(normalized_headers):
|
||||
raise TabularSourceValidationError("CSV column names must be unique.")
|
||||
rows: list[dict[str, object]] = []
|
||||
for row in reader:
|
||||
extras = row.get(None)
|
||||
if extras and any(str(value or "").strip() for value in extras):
|
||||
raise TabularSourceValidationError(
|
||||
"A CSV row contains more values than the header defines."
|
||||
)
|
||||
values = [row.get(original) for original in reader.fieldnames]
|
||||
if all(value is None or not value.strip() for value in values):
|
||||
continue
|
||||
if len(rows) >= max_rows:
|
||||
raise TabularSourceValidationError(
|
||||
f"CSV snapshots are limited to {max_rows:,} rows."
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
normalized_headers[position]: _csv_scalar(row.get(original))
|
||||
for position, original in enumerate(reader.fieldnames)
|
||||
}
|
||||
)
|
||||
return tuple(rows)
|
||||
except csv.Error as exc:
|
||||
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
|
||||
|
||||
|
||||
def _csv_scalar(value: str | None) -> object:
|
||||
if value is None:
|
||||
return None
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
lowered = text.casefold()
|
||||
if lowered in {"true", "false"}:
|
||||
return lowered == "true"
|
||||
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
|
||||
return int(text)
|
||||
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", text):
|
||||
return float(text)
|
||||
return text
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularColumn:
|
||||
name: str
|
||||
data_type: str
|
||||
nullable: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularSource:
|
||||
"""Opaque, policy-filtered source reference exposed to consuming modules."""
|
||||
|
||||
ref: str
|
||||
provider: str
|
||||
source_name: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
schema: tuple[TabularColumn, ...] = ()
|
||||
schema_version: str = "1"
|
||||
fingerprint: str = ""
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
updated_at: datetime | None = None
|
||||
capabilities: tuple[str, ...] = ("read",)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularReadRequest:
|
||||
source_ref: str
|
||||
limit: int = 250
|
||||
offset: int = 0
|
||||
columns: tuple[str, ...] = ()
|
||||
expected_fingerprint: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularReadResult:
|
||||
source: TabularSource
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
total_rows: int
|
||||
truncated: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularSnapshotInput:
|
||||
name: str
|
||||
source_name: str
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
description: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TabularSourceProvider(Protocol):
|
||||
"""Principal-aware catalogue and bounded reader for tabular sources."""
|
||||
|
||||
def list_sources(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
) -> Sequence[TabularSource]:
|
||||
...
|
||||
|
||||
def get_source(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
source_ref: str,
|
||||
) -> TabularSource | None:
|
||||
...
|
||||
|
||||
def read_source(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: TabularReadRequest,
|
||||
) -> TabularReadResult:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TabularSnapshotWriter(Protocol):
|
||||
"""Optional capability for importing bounded static tabular snapshots."""
|
||||
|
||||
def create_snapshot(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
snapshot: TabularSnapshotInput,
|
||||
) -> TabularSource:
|
||||
...
|
||||
|
||||
|
||||
def tabular_source_provider(registry: object | None) -> TabularSourceProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SOURCES)
|
||||
return capability if isinstance(capability, TabularSourceProvider) else None
|
||||
|
||||
|
||||
def tabular_snapshot_writer(registry: object | None) -> TabularSnapshotWriter | None:
|
||||
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER)
|
||||
return capability if isinstance(capability, TabularSnapshotWriter) else None
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER",
|
||||
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
|
||||
"TabularColumn",
|
||||
"TabularReadRequest",
|
||||
"TabularReadResult",
|
||||
"TabularSnapshotInput",
|
||||
"TabularSnapshotWriter",
|
||||
"TabularSource",
|
||||
"TabularSourceAccessError",
|
||||
"TabularSourceError",
|
||||
"TabularSourceNotFoundError",
|
||||
"TabularSourceProvider",
|
||||
"TabularSourceValidationError",
|
||||
"parse_tabular_csv",
|
||||
"tabular_snapshot_writer",
|
||||
"tabular_source_provider",
|
||||
]
|
||||
94
src/govoplan_core/core/views.py
Normal file
94
src/govoplan_core/core/views.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
VIEWS_MODULE_ID = "views"
|
||||
CAPABILITY_VIEWS_RESOLVER = "views.resolver"
|
||||
VIEW_SURFACE_CONTRACT_VERSION = "1"
|
||||
|
||||
ViewSurfaceKind = Literal[
|
||||
"module",
|
||||
"navigation",
|
||||
"route",
|
||||
"section",
|
||||
"action",
|
||||
"selector",
|
||||
]
|
||||
|
||||
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
|
||||
_SURFACE_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ViewSurface:
|
||||
id: str
|
||||
module_id: str
|
||||
kind: ViewSurfaceKind
|
||||
label: str
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
order: int = 100
|
||||
default_visible: bool = True
|
||||
required: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectiveView:
|
||||
view_id: str | None
|
||||
revision_id: str | None
|
||||
name: str | None
|
||||
visible_surface_ids: frozenset[str]
|
||||
locked: bool = False
|
||||
provenance: tuple[dict[str, object], ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ViewResolver(Protocol):
|
||||
def resolve_effective_view(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
group_ids: Iterable[str] = (),
|
||||
) -> EffectiveView: ...
|
||||
|
||||
|
||||
def module_view_surface_id(module_id: str) -> str:
|
||||
return f"{module_id}.module"
|
||||
|
||||
|
||||
def navigation_view_surface_id(module_id: str, path: str) -> str:
|
||||
return f"{module_id}.nav.{_surface_slug(path)}"
|
||||
|
||||
|
||||
def route_view_surface_id(module_id: str, path: str) -> str:
|
||||
return f"{module_id}.route.{_surface_slug(path)}"
|
||||
|
||||
|
||||
def validate_view_surface_id(value: str) -> bool:
|
||||
return bool(_SURFACE_ID_RE.fullmatch(value))
|
||||
|
||||
|
||||
def _surface_slug(value: str) -> str:
|
||||
normalized = _SURFACE_SLUG_RE.sub(".", value.strip().lower()).strip(".")
|
||||
return normalized or "root"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_VIEWS_RESOLVER",
|
||||
"EffectiveView",
|
||||
"VIEWS_MODULE_ID",
|
||||
"VIEW_SURFACE_CONTRACT_VERSION",
|
||||
"ViewResolver",
|
||||
"ViewSurface",
|
||||
"ViewSurfaceKind",
|
||||
"module_view_surface_id",
|
||||
"navigation_view_surface_id",
|
||||
"route_view_surface_id",
|
||||
"validate_view_surface_id",
|
||||
]
|
||||
@@ -32,6 +32,7 @@ def create_all_tables() -> None:
|
||||
# model metadata with the shared SQLAlchemy base before create_all runs.
|
||||
from govoplan_core.admin import models as core_admin_models # noqa: F401
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
|
||||
|
||||
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
||||
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
|
||||
|
||||
563
src/govoplan_core/security/credential_envelopes.py
Normal file
563
src/govoplan_core/security/credential_envelopes.py
Normal file
@@ -0,0 +1,563 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Index, JSON, String, Text, select
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.core.change_sequence import record_change
|
||||
from govoplan_core.db.base import Base, TimestampMixin, utcnow
|
||||
from govoplan_core.security.redaction import is_sensitive_key, redact_secret_values
|
||||
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||
|
||||
|
||||
CREDENTIAL_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user", "campaign"})
|
||||
CREDENTIAL_KINDS = frozenset(
|
||||
{
|
||||
"username_password",
|
||||
"token",
|
||||
"oauth2",
|
||||
"api_key",
|
||||
"client_secret",
|
||||
"aws",
|
||||
"custom",
|
||||
"external_secret",
|
||||
}
|
||||
)
|
||||
_SECRET_PUBLIC_DATA_KEYS = frozenset(
|
||||
{
|
||||
"access_token",
|
||||
"api_key",
|
||||
"authorization",
|
||||
"bearer_token",
|
||||
"client_secret",
|
||||
"password",
|
||||
"passphrase",
|
||||
"private_key",
|
||||
"refresh_token",
|
||||
"secret",
|
||||
"secret_access_key",
|
||||
"session_token",
|
||||
"token",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CredentialEnvelopeError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CredentialEnvelope(Base, TimestampMixin):
|
||||
__tablename__ = "core_credential_envelopes"
|
||||
__table_args__ = (
|
||||
Index("ix_core_credential_envelopes_scope", "tenant_id", "scope_type", "scope_id"),
|
||||
Index("ix_core_credential_envelopes_active", "tenant_id", "is_active", "deleted_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, default="tenant", index=True)
|
||||
scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
credential_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
public_data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
secret_data_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
secret_keys: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
allowed_modules: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
allowed_server_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
revision: Mapped[str] = mapped_column(String(36), default=lambda: str(uuid.uuid4()), nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
updated_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CredentialAccessContext:
|
||||
tenant_id: str | None
|
||||
user_id: str | None = None
|
||||
group_ids: frozenset[str] = frozenset()
|
||||
target_scope_type: str = "tenant"
|
||||
target_scope_id: str | None = None
|
||||
module_id: str | None = None
|
||||
server_ref: str | None = None
|
||||
administrative: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedCredentialEnvelope:
|
||||
id: str
|
||||
name: str
|
||||
credential_kind: str
|
||||
public_data: Mapping[str, Any]
|
||||
secret_data: Mapping[str, Any]
|
||||
revision: str
|
||||
|
||||
|
||||
def normalize_credential_scope(
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
) -> tuple[str | None, str, str | None]:
|
||||
normalized_type = str(scope_type or "tenant").strip().casefold()
|
||||
if normalized_type not in CREDENTIAL_SCOPE_TYPES:
|
||||
raise CredentialEnvelopeError(f"Unsupported credential scope: {scope_type!r}")
|
||||
normalized_id = _optional_text(scope_id)
|
||||
if normalized_type == "system":
|
||||
return None, "system", None
|
||||
normalized_tenant = _required_text(tenant_id, "Credential tenant_id is required outside system scope")
|
||||
if normalized_type == "tenant":
|
||||
return normalized_tenant, "tenant", normalized_id or normalized_tenant
|
||||
if not normalized_id:
|
||||
raise CredentialEnvelopeError(f"{normalized_type.capitalize()} credentials require scope_id")
|
||||
return normalized_tenant, normalized_type, normalized_id
|
||||
|
||||
|
||||
def create_credential_envelope(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
name: str,
|
||||
credential_kind: str,
|
||||
public_data: Mapping[str, Any] | None = None,
|
||||
secret_data: Mapping[str, Any] | None = None,
|
||||
description: str | None = None,
|
||||
allowed_modules: list[str] | tuple[str, ...] = (),
|
||||
allowed_server_refs: list[str] | tuple[str, ...] = (),
|
||||
inherit_to_lower_scopes: bool = False,
|
||||
is_active: bool = True,
|
||||
user_id: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> CredentialEnvelope:
|
||||
row_tenant_id, row_scope_type, row_scope_id = normalize_credential_scope(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
clean_kind = _normalize_kind(credential_kind)
|
||||
clean_public = _safe_public_mapping(public_data)
|
||||
clean_secret = _json_mapping(secret_data)
|
||||
row = CredentialEnvelope(
|
||||
tenant_id=row_tenant_id,
|
||||
scope_type=row_scope_type,
|
||||
scope_id=row_scope_id,
|
||||
name=_required_text(name, "Credential name is required"),
|
||||
description=_optional_text(description),
|
||||
credential_kind=clean_kind,
|
||||
public_data=clean_public,
|
||||
secret_data_encrypted=_encrypt_secret_mapping(clean_secret),
|
||||
secret_keys=sorted(clean_secret),
|
||||
allowed_modules=_normalized_values(allowed_modules),
|
||||
allowed_server_refs=_normalized_values(allowed_server_refs),
|
||||
inherit_to_lower_scopes=bool(inherit_to_lower_scopes),
|
||||
is_active=bool(is_active),
|
||||
created_by_user_id=_optional_text(user_id),
|
||||
updated_by_user_id=_optional_text(user_id),
|
||||
metadata_=_json_mapping(metadata) or None,
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
_record_credential_change(session, row=row, operation="created", user_id=user_id)
|
||||
return row
|
||||
|
||||
|
||||
def update_credential_envelope(
|
||||
session: Session,
|
||||
row: CredentialEnvelope,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
description_supplied: bool = False,
|
||||
credential_kind: str | None = None,
|
||||
public_data: Mapping[str, Any] | None = None,
|
||||
secret_data: Mapping[str, Any] | None = None,
|
||||
clear_secret: bool = False,
|
||||
allowed_modules: list[str] | tuple[str, ...] | None = None,
|
||||
allowed_server_refs: list[str] | tuple[str, ...] | None = None,
|
||||
inherit_to_lower_scopes: bool | None = None,
|
||||
is_active: bool | None = None,
|
||||
user_id: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> CredentialEnvelope:
|
||||
if row.deleted_at is not None:
|
||||
raise CredentialEnvelopeError("Deleted credentials cannot be updated")
|
||||
if name is not None:
|
||||
row.name = _required_text(name, "Credential name is required")
|
||||
if description_supplied or description is not None:
|
||||
row.description = _optional_text(description)
|
||||
if credential_kind is not None:
|
||||
clean_kind = _normalize_kind(credential_kind)
|
||||
if clean_kind != row.credential_kind and secret_data is None and not clear_secret:
|
||||
raise CredentialEnvelopeError(
|
||||
"Changing credential kind requires replacing or clearing its secret"
|
||||
)
|
||||
row.credential_kind = clean_kind
|
||||
if public_data is not None:
|
||||
row.public_data = _safe_public_mapping(public_data)
|
||||
if secret_data is not None:
|
||||
clean_secret = _json_mapping(secret_data)
|
||||
row.secret_data_encrypted = _encrypt_secret_mapping(clean_secret)
|
||||
row.secret_keys = sorted(clean_secret)
|
||||
elif clear_secret:
|
||||
row.secret_data_encrypted = None
|
||||
row.secret_keys = []
|
||||
if allowed_modules is not None:
|
||||
row.allowed_modules = _normalized_values(allowed_modules)
|
||||
if allowed_server_refs is not None:
|
||||
row.allowed_server_refs = _normalized_values(allowed_server_refs)
|
||||
if inherit_to_lower_scopes is not None:
|
||||
row.inherit_to_lower_scopes = bool(inherit_to_lower_scopes)
|
||||
if is_active is not None:
|
||||
row.is_active = bool(is_active)
|
||||
if metadata is not None:
|
||||
row.metadata_ = _json_mapping(metadata) or None
|
||||
row.updated_by_user_id = _optional_text(user_id)
|
||||
row.revision = str(uuid.uuid4())
|
||||
session.flush()
|
||||
_record_credential_change(session, row=row, operation="updated", user_id=user_id)
|
||||
return row
|
||||
|
||||
|
||||
def retire_credential_envelope(
|
||||
session: Session,
|
||||
row: CredentialEnvelope,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
) -> CredentialEnvelope:
|
||||
if row.deleted_at is not None:
|
||||
return row
|
||||
row.secret_data_encrypted = None
|
||||
row.secret_keys = []
|
||||
row.is_active = False
|
||||
row.deleted_at = utcnow()
|
||||
row.updated_by_user_id = _optional_text(user_id)
|
||||
row.revision = str(uuid.uuid4())
|
||||
session.flush()
|
||||
_record_credential_change(session, row=row, operation="deleted", user_id=user_id)
|
||||
return row
|
||||
|
||||
|
||||
def get_credential_envelope(
|
||||
session: Session,
|
||||
*,
|
||||
credential_id: str,
|
||||
context: CredentialAccessContext,
|
||||
require_active: bool = True,
|
||||
for_update: bool = False,
|
||||
) -> CredentialEnvelope:
|
||||
statement = select(CredentialEnvelope).where(CredentialEnvelope.id == _required_text(credential_id, "Credential id is required"))
|
||||
if for_update:
|
||||
statement = statement.with_for_update()
|
||||
row = session.execute(statement).scalar_one_or_none()
|
||||
if row is None or row.deleted_at is not None or not credential_visible_to_context(row, context):
|
||||
raise CredentialEnvelopeError("Credential envelope not found")
|
||||
if require_active and not row.is_active:
|
||||
raise CredentialEnvelopeError("Credential envelope is inactive")
|
||||
return row
|
||||
|
||||
|
||||
def list_credential_envelopes(
|
||||
session: Session,
|
||||
*,
|
||||
context: CredentialAccessContext,
|
||||
include_inactive: bool = False,
|
||||
) -> list[CredentialEnvelope]:
|
||||
statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None))
|
||||
if context.tenant_id is None:
|
||||
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
|
||||
else:
|
||||
statement = statement.where(
|
||||
(CredentialEnvelope.tenant_id == context.tenant_id)
|
||||
| (CredentialEnvelope.tenant_id.is_(None))
|
||||
)
|
||||
if not include_inactive:
|
||||
statement = statement.where(CredentialEnvelope.is_active.is_(True))
|
||||
rows = session.execute(statement.order_by(CredentialEnvelope.name, CredentialEnvelope.id)).scalars()
|
||||
return [row for row in rows if credential_visible_to_context(row, context)]
|
||||
|
||||
|
||||
def list_managed_credential_envelopes(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
include_inactive: bool = False,
|
||||
) -> list[CredentialEnvelope]:
|
||||
"""List envelopes for an administrative surface without applying use-site limits."""
|
||||
|
||||
statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None))
|
||||
if tenant_id is None:
|
||||
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
|
||||
else:
|
||||
statement = statement.where(CredentialEnvelope.tenant_id == tenant_id)
|
||||
if not include_inactive:
|
||||
statement = statement.where(CredentialEnvelope.is_active.is_(True))
|
||||
return list(
|
||||
session.execute(
|
||||
statement.order_by(
|
||||
CredentialEnvelope.scope_type,
|
||||
CredentialEnvelope.name,
|
||||
CredentialEnvelope.id,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def get_managed_credential_envelope(
|
||||
session: Session,
|
||||
*,
|
||||
credential_id: str,
|
||||
tenant_id: str | None,
|
||||
for_update: bool = False,
|
||||
) -> CredentialEnvelope:
|
||||
statement = select(CredentialEnvelope).where(
|
||||
CredentialEnvelope.id
|
||||
== _required_text(credential_id, "Credential id is required"),
|
||||
CredentialEnvelope.deleted_at.is_(None),
|
||||
)
|
||||
if tenant_id is None:
|
||||
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
|
||||
else:
|
||||
statement = statement.where(CredentialEnvelope.tenant_id == tenant_id)
|
||||
if for_update:
|
||||
statement = statement.with_for_update()
|
||||
row = session.execute(statement).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise CredentialEnvelopeError("Credential envelope not found")
|
||||
return row
|
||||
|
||||
|
||||
def credential_visible_to_context(row: CredentialEnvelope, context: CredentialAccessContext) -> bool:
|
||||
if row.deleted_at is not None:
|
||||
return False
|
||||
if not _scope_visible(row, context):
|
||||
return False
|
||||
if row.allowed_modules and (not context.module_id or context.module_id not in row.allowed_modules):
|
||||
return False
|
||||
if row.allowed_server_refs and (not context.server_ref or context.server_ref not in row.allowed_server_refs):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def resolve_credential_envelope(
|
||||
session: Session,
|
||||
*,
|
||||
credential_id: str,
|
||||
context: CredentialAccessContext,
|
||||
) -> ResolvedCredentialEnvelope:
|
||||
row = get_credential_envelope(
|
||||
session,
|
||||
credential_id=credential_id,
|
||||
context=context,
|
||||
require_active=True,
|
||||
)
|
||||
return ResolvedCredentialEnvelope(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
credential_kind=row.credential_kind,
|
||||
public_data=dict(row.public_data or {}),
|
||||
secret_data=_decrypt_secret_mapping(row.secret_data_encrypted),
|
||||
revision=row.revision,
|
||||
)
|
||||
|
||||
|
||||
def credential_envelope_summary(row: CredentialEnvelope) -> dict[str, Any]:
|
||||
public_data = redact_secret_values(dict(row.public_data or {}))
|
||||
return {
|
||||
"id": row.id,
|
||||
"tenant_id": row.tenant_id,
|
||||
"scope_type": row.scope_type,
|
||||
"scope_id": row.scope_id,
|
||||
"name": row.name,
|
||||
"description": row.description,
|
||||
"credential_kind": row.credential_kind,
|
||||
"public_data": public_data,
|
||||
"secret_keys": sorted(str(key) for key in (row.secret_keys or [])),
|
||||
"secret_configured": bool(row.secret_data_encrypted),
|
||||
"allowed_modules": sorted(str(item) for item in (row.allowed_modules or [])),
|
||||
"allowed_server_refs": sorted(str(item) for item in (row.allowed_server_refs or [])),
|
||||
"inherit_to_lower_scopes": bool(row.inherit_to_lower_scopes),
|
||||
"is_active": bool(row.is_active),
|
||||
"revision": row.revision,
|
||||
"created_at": row.created_at,
|
||||
"updated_at": row.updated_at,
|
||||
"deleted_at": row.deleted_at,
|
||||
}
|
||||
|
||||
|
||||
def _scope_visible(row: CredentialEnvelope, context: CredentialAccessContext) -> bool:
|
||||
target_type = str(context.target_scope_type or "tenant").strip().casefold()
|
||||
target_id = _optional_text(context.target_scope_id)
|
||||
if context.administrative:
|
||||
return row.scope_type == "system" or row.tenant_id == context.tenant_id
|
||||
if row.scope_type == "system":
|
||||
return target_type == "system" or bool(row.inherit_to_lower_scopes)
|
||||
if row.tenant_id != context.tenant_id:
|
||||
return False
|
||||
if row.scope_type == "tenant":
|
||||
if target_type == "tenant":
|
||||
return row.scope_id in {None, context.tenant_id, target_id}
|
||||
return bool(row.inherit_to_lower_scopes)
|
||||
if row.scope_type == "user":
|
||||
return row.scope_id == context.user_id or (target_type == "user" and row.scope_id == target_id)
|
||||
if row.scope_type == "group":
|
||||
exact = row.scope_id in context.group_ids or (target_type == "group" and row.scope_id == target_id)
|
||||
return exact and (target_type == "group" or bool(row.inherit_to_lower_scopes))
|
||||
if row.scope_type == "campaign":
|
||||
return target_type == "campaign" and row.scope_id == target_id
|
||||
return False
|
||||
|
||||
|
||||
def _record_credential_change(
|
||||
session: Session,
|
||||
*,
|
||||
row: CredentialEnvelope,
|
||||
operation: str,
|
||||
user_id: str | None,
|
||||
) -> None:
|
||||
scope = "system" if row.scope_type == "system" else "tenant"
|
||||
details = {
|
||||
"scope_type": row.scope_type,
|
||||
"scope_id": row.scope_id,
|
||||
"credential_kind": row.credential_kind,
|
||||
"allowed_modules": list(row.allowed_modules or []),
|
||||
"allowed_server_refs": list(row.allowed_server_refs or []),
|
||||
"secret_configured": bool(row.secret_data_encrypted),
|
||||
}
|
||||
record_change(
|
||||
session,
|
||||
module_id="core",
|
||||
collection="core.security.credentials",
|
||||
resource_type="credential_envelope",
|
||||
resource_id=row.id,
|
||||
operation=operation,
|
||||
tenant_id=row.tenant_id,
|
||||
actor_type="user" if user_id else None,
|
||||
actor_id=user_id,
|
||||
payload={"scope_type": row.scope_type, "credential_kind": row.credential_kind},
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=row.tenant_id,
|
||||
action=f"credential.{operation}",
|
||||
scope=scope,
|
||||
user_id=user_id,
|
||||
object_type="credential_envelope",
|
||||
object_id=row.id,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def _encrypt_secret_mapping(value: Mapping[str, Any]) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return encrypt_secret(json.dumps(dict(value), separators=(",", ":"), sort_keys=True))
|
||||
|
||||
|
||||
def _decrypt_secret_mapping(value: str | None) -> dict[str, Any]:
|
||||
decrypted = decrypt_secret(value)
|
||||
if not decrypted:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(decrypted)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise CredentialEnvelopeError("Stored credential payload is invalid") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise CredentialEnvelopeError("Stored credential payload is invalid")
|
||||
return parsed
|
||||
|
||||
|
||||
def _normalize_kind(value: str) -> str:
|
||||
normalized = _required_text(value, "Credential kind is required").casefold()
|
||||
if normalized not in CREDENTIAL_KINDS:
|
||||
raise CredentialEnvelopeError(f"Unsupported credential kind: {value!r}")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalized_values(values: list[str] | tuple[str, ...]) -> list[str]:
|
||||
return sorted({_required_text(value, "Credential policy values cannot be blank") for value in values})
|
||||
|
||||
|
||||
def _json_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return dict(value or {})
|
||||
|
||||
|
||||
def _safe_public_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
payload = _json_mapping(value)
|
||||
unsafe_path = _secret_public_data_path(payload)
|
||||
if unsafe_path:
|
||||
raise CredentialEnvelopeError(
|
||||
f"Credential public_data must not contain secret field {unsafe_path!r}"
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _secret_public_data_path(value: object, path: str = "public_data") -> str | None:
|
||||
if isinstance(value, Mapping):
|
||||
for key, item in value.items():
|
||||
item_path = f"{path}.{key}"
|
||||
if _is_secret_public_key(key):
|
||||
return item_path
|
||||
nested_path = _secret_public_data_path(item, item_path)
|
||||
if nested_path:
|
||||
return nested_path
|
||||
elif isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
nested_path = _secret_public_data_path(item, f"{path}[{index}]")
|
||||
if nested_path:
|
||||
return nested_path
|
||||
return None
|
||||
|
||||
|
||||
def _is_secret_public_key(key: object) -> bool:
|
||||
clean_key = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key).strip())
|
||||
clean_key = re.sub(r"[^A-Za-z0-9]+", "_", clean_key).strip("_").casefold()
|
||||
if clean_key.endswith(("_id", "_ref", "_reference", "_type")):
|
||||
return False
|
||||
return clean_key in _SECRET_PUBLIC_DATA_KEYS or is_sensitive_key(key)
|
||||
|
||||
|
||||
def _required_text(value: object | None, message: str) -> str:
|
||||
clean = _optional_text(value)
|
||||
if not clean:
|
||||
raise CredentialEnvelopeError(message)
|
||||
return clean
|
||||
|
||||
|
||||
def _optional_text(value: object | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
clean = str(value).strip()
|
||||
return clean or None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CREDENTIAL_KINDS",
|
||||
"CREDENTIAL_SCOPE_TYPES",
|
||||
"CredentialAccessContext",
|
||||
"CredentialEnvelope",
|
||||
"CredentialEnvelopeError",
|
||||
"ResolvedCredentialEnvelope",
|
||||
"create_credential_envelope",
|
||||
"credential_envelope_summary",
|
||||
"credential_visible_to_context",
|
||||
"get_credential_envelope",
|
||||
"get_managed_credential_envelope",
|
||||
"list_credential_envelopes",
|
||||
"list_managed_credential_envelopes",
|
||||
"normalize_credential_scope",
|
||||
"resolve_credential_envelope",
|
||||
"retire_credential_envelope",
|
||||
"update_credential_envelope",
|
||||
]
|
||||
@@ -8,6 +8,7 @@ from govoplan_core.db.session import configure_database
|
||||
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
|
||||
from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.credentials import router as credential_router
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
from govoplan_core.server.route_validation import validate_no_route_collisions
|
||||
|
||||
@@ -67,6 +68,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
|
||||
for router in server_config.base_routers:
|
||||
api_router.include_router(router)
|
||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||
api_router.include_router(credential_router)
|
||||
for router in server_config.post_module_routers:
|
||||
api_router.include_router(router)
|
||||
for contribution in server_config.extra_routers:
|
||||
|
||||
380
src/govoplan_core/server/credentials.py
Normal file
380
src/govoplan_core/server/credentials.py
Normal file
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.credential_envelopes import (
|
||||
CredentialEnvelope,
|
||||
CredentialEnvelopeError,
|
||||
create_credential_envelope,
|
||||
credential_envelope_summary,
|
||||
get_managed_credential_envelope,
|
||||
list_managed_credential_envelopes,
|
||||
normalize_credential_scope,
|
||||
retire_credential_envelope,
|
||||
update_credential_envelope,
|
||||
)
|
||||
|
||||
|
||||
CredentialScopeType = Literal["system", "tenant", "group", "user", "campaign"]
|
||||
|
||||
|
||||
class CredentialEnvelopeResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
scope_type: CredentialScopeType
|
||||
scope_id: str | None = None
|
||||
name: str
|
||||
description: str | None = None
|
||||
credential_kind: str
|
||||
public_data: dict[str, Any] = Field(default_factory=dict)
|
||||
secret_keys: list[str] = Field(default_factory=list)
|
||||
secret_configured: bool = False
|
||||
allowed_modules: list[str] = Field(default_factory=list)
|
||||
allowed_server_refs: list[str] = Field(default_factory=list)
|
||||
inherit_to_lower_scopes: bool = False
|
||||
is_active: bool = True
|
||||
revision: str
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
class CredentialEnvelopeListResponse(BaseModel):
|
||||
credentials: list[CredentialEnvelopeResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CredentialEnvelopeCreateRequest(BaseModel):
|
||||
scope_type: CredentialScopeType = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
credential_kind: str = Field(default="username_password", max_length=40)
|
||||
public_data: dict[str, Any] = Field(default_factory=dict)
|
||||
secret_data: dict[str, SecretStr] = Field(default_factory=dict)
|
||||
allowed_modules: list[str] = Field(default_factory=list)
|
||||
allowed_server_refs: list[str] = Field(default_factory=list)
|
||||
inherit_to_lower_scopes: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class CredentialEnvelopeUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
credential_kind: str | None = Field(default=None, max_length=40)
|
||||
public_data: dict[str, Any] | None = None
|
||||
secret_data: dict[str, SecretStr] | None = None
|
||||
clear_secret: bool = False
|
||||
allowed_modules: list[str] | None = None
|
||||
allowed_server_refs: list[str] | None = None
|
||||
inherit_to_lower_scopes: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
router = APIRouter(prefix="/credentials", tags=["credentials"])
|
||||
|
||||
|
||||
@router.get("", response_model=CredentialEnvelopeListResponse)
|
||||
def list_credentials(
|
||||
scope_type: CredentialScopeType = Query(default="tenant"),
|
||||
scope_id: str | None = Query(default=None),
|
||||
include_inactive: bool = Query(default=False),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> CredentialEnvelopeListResponse:
|
||||
tenant_id, normalized_scope_type, normalized_scope_id = _target_scope(
|
||||
principal,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
write=False,
|
||||
)
|
||||
rows = list_managed_credential_envelopes(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
return CredentialEnvelopeListResponse(
|
||||
credentials=[
|
||||
_response(row)
|
||||
for row in rows
|
||||
if row.scope_type == normalized_scope_type
|
||||
and row.scope_id == normalized_scope_id
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=CredentialEnvelopeResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_credential(
|
||||
payload: CredentialEnvelopeCreateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> CredentialEnvelopeResponse:
|
||||
tenant_id, scope_type, scope_id = _target_scope(
|
||||
principal,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
write=True,
|
||||
)
|
||||
try:
|
||||
row = create_credential_envelope(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
credential_kind=payload.credential_kind,
|
||||
public_data=payload.public_data,
|
||||
secret_data=_secret_values(payload.secret_data),
|
||||
allowed_modules=payload.allowed_modules,
|
||||
allowed_server_refs=payload.allowed_server_refs,
|
||||
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
||||
is_active=payload.is_active,
|
||||
user_id=principal.membership_id,
|
||||
metadata={"created_by_module": "core"},
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return _response(row)
|
||||
except CredentialEnvelopeError as exc:
|
||||
session.rollback()
|
||||
raise _credential_error(exc) from exc
|
||||
|
||||
|
||||
@router.patch("/{credential_id}", response_model=CredentialEnvelopeResponse)
|
||||
def update_credential(
|
||||
credential_id: str,
|
||||
payload: CredentialEnvelopeUpdateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> CredentialEnvelopeResponse:
|
||||
try:
|
||||
row = _managed_row_for_write(session, principal, credential_id)
|
||||
row = update_credential_envelope(
|
||||
session,
|
||||
row,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
description_supplied="description" in payload.model_fields_set,
|
||||
credential_kind=payload.credential_kind,
|
||||
public_data=payload.public_data,
|
||||
secret_data=(
|
||||
_secret_values(payload.secret_data)
|
||||
if payload.secret_data is not None
|
||||
else None
|
||||
),
|
||||
clear_secret=payload.clear_secret,
|
||||
allowed_modules=payload.allowed_modules,
|
||||
allowed_server_refs=payload.allowed_server_refs,
|
||||
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
||||
is_active=payload.is_active,
|
||||
user_id=principal.membership_id,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return _response(row)
|
||||
except CredentialEnvelopeError as exc:
|
||||
session.rollback()
|
||||
raise _credential_error(exc) from exc
|
||||
|
||||
|
||||
@router.delete("/{credential_id}", response_model=CredentialEnvelopeResponse)
|
||||
def delete_credential(
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> CredentialEnvelopeResponse:
|
||||
try:
|
||||
row = _managed_row_for_write(session, principal, credential_id)
|
||||
retire_credential_envelope(
|
||||
session,
|
||||
row,
|
||||
user_id=principal.membership_id,
|
||||
)
|
||||
response = _response(row)
|
||||
session.commit()
|
||||
return response
|
||||
except CredentialEnvelopeError as exc:
|
||||
session.rollback()
|
||||
raise _credential_error(exc) from exc
|
||||
|
||||
|
||||
def _managed_row_for_write(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
credential_id: str,
|
||||
) -> CredentialEnvelope:
|
||||
system_row = None
|
||||
if _can_manage_system_credentials(principal):
|
||||
try:
|
||||
system_row = get_managed_credential_envelope(
|
||||
session,
|
||||
credential_id=credential_id,
|
||||
tenant_id=None,
|
||||
for_update=True,
|
||||
)
|
||||
except CredentialEnvelopeError:
|
||||
pass
|
||||
row = system_row or get_managed_credential_envelope(
|
||||
session,
|
||||
credential_id=credential_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
for_update=True,
|
||||
)
|
||||
_target_scope(
|
||||
principal,
|
||||
scope_type=row.scope_type,
|
||||
scope_id=row.scope_id,
|
||||
write=True,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def _target_scope(
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
write: bool,
|
||||
) -> tuple[str | None, str, str | None]:
|
||||
requested_type = str(scope_type or "tenant").strip().casefold()
|
||||
requested_id = str(scope_id).strip() if scope_id else None
|
||||
if requested_type == "system":
|
||||
if not (
|
||||
_can_manage_system_credentials(principal)
|
||||
if write
|
||||
else _can_read_system_credentials(principal)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="System credential permission is required.",
|
||||
)
|
||||
return _normalize_target_scope(
|
||||
tenant_id=None,
|
||||
scope_type="system",
|
||||
scope_id=None,
|
||||
)
|
||||
|
||||
if requested_type == "user" and requested_id == principal.membership_id:
|
||||
own_scope = (
|
||||
has_scope(principal, "access:credential:manage_own")
|
||||
or has_scope(principal, "mail:secret:manage_own")
|
||||
)
|
||||
if own_scope:
|
||||
return _normalize_target_scope(
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=requested_type,
|
||||
scope_id=requested_id,
|
||||
)
|
||||
|
||||
allowed = (
|
||||
_can_manage_tenant_credentials(principal)
|
||||
if write
|
||||
else _can_read_tenant_credentials(principal)
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Tenant credential permission is required.",
|
||||
)
|
||||
return _normalize_target_scope(
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=requested_type,
|
||||
scope_id=requested_id,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_target_scope(
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
) -> tuple[str | None, str, str | None]:
|
||||
try:
|
||||
return normalize_credential_scope(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
except CredentialEnvelopeError as exc:
|
||||
raise _credential_error(exc) from exc
|
||||
|
||||
|
||||
def _can_read_system_credentials(principal: ApiPrincipal) -> bool:
|
||||
return any(
|
||||
has_scope(principal, scope)
|
||||
for scope in (
|
||||
"access:system_credential:read",
|
||||
"access:system_setting:read",
|
||||
"system:settings:read",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _can_manage_system_credentials(principal: ApiPrincipal) -> bool:
|
||||
return any(
|
||||
has_scope(principal, scope)
|
||||
for scope in (
|
||||
"access:system_credential:write",
|
||||
"access:system_setting:write",
|
||||
"system:settings:write",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _can_read_tenant_credentials(principal: ApiPrincipal) -> bool:
|
||||
return any(
|
||||
has_scope(principal, scope)
|
||||
for scope in (
|
||||
"access:credential:read",
|
||||
"access:setting:read",
|
||||
"admin:settings:read",
|
||||
"mail_servers:read",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _can_manage_tenant_credentials(principal: ApiPrincipal) -> bool:
|
||||
return any(
|
||||
has_scope(principal, scope)
|
||||
for scope in (
|
||||
"access:credential:write",
|
||||
"access:setting:write",
|
||||
"admin:settings:write",
|
||||
"mail_servers:manage_credentials",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _secret_values(values: dict[str, SecretStr]) -> dict[str, str]:
|
||||
return {
|
||||
str(key): value.get_secret_value()
|
||||
for key, value in values.items()
|
||||
if str(key).strip() and value.get_secret_value()
|
||||
}
|
||||
|
||||
|
||||
def _response(row: CredentialEnvelope) -> CredentialEnvelopeResponse:
|
||||
return CredentialEnvelopeResponse.model_validate(
|
||||
credential_envelope_summary(row)
|
||||
)
|
||||
|
||||
|
||||
def _credential_error(exc: CredentialEnvelopeError) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -6,8 +6,14 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
|
||||
from govoplan_core.core.views import (
|
||||
VIEW_SURFACE_CONTRACT_VERSION,
|
||||
ViewSurface,
|
||||
navigation_view_surface_id,
|
||||
route_view_surface_id,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.i18n import system_i18n_payload
|
||||
|
||||
@@ -19,7 +25,7 @@ def _registry(request: Request) -> PlatformRegistry:
|
||||
return registry
|
||||
|
||||
|
||||
def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
||||
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"path": item.path,
|
||||
"label": item.label,
|
||||
@@ -28,16 +34,26 @@ def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
||||
"required_all": list(item.required_all),
|
||||
"required_any": list(item.required_any),
|
||||
"order": item.order,
|
||||
"surface_id": (
|
||||
item.surface_id or navigation_view_surface_id(module_id, item.path)
|
||||
if module_id
|
||||
else item.surface_id
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
|
||||
def _frontend_route_payload(route: FrontendRoute, module_id: str | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"path": route.path,
|
||||
"component": route.component,
|
||||
"required_all": list(route.required_all),
|
||||
"required_any": list(route.required_any),
|
||||
"order": route.order,
|
||||
"surface_id": (
|
||||
route.surface_id or route_view_surface_id(module_id, route.path)
|
||||
if module_id
|
||||
else route.surface_id
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +65,29 @@ def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, obje
|
||||
}
|
||||
|
||||
|
||||
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
||||
def _view_surface_payload(surface: ViewSurface) -> dict[str, object]:
|
||||
return {
|
||||
"id": surface.id,
|
||||
"module_id": surface.module_id,
|
||||
"kind": surface.kind,
|
||||
"label": surface.label,
|
||||
"parent_id": surface.parent_id,
|
||||
"description": surface.description,
|
||||
"order": surface.order,
|
||||
"default_visible": surface.default_visible,
|
||||
"required": surface.required,
|
||||
}
|
||||
|
||||
|
||||
def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]:
|
||||
return [
|
||||
_view_surface_payload(surface)
|
||||
for surface in manifest_view_surfaces(manifest)
|
||||
]
|
||||
|
||||
|
||||
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
return None
|
||||
return {
|
||||
@@ -60,12 +98,14 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
||||
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
||||
"routes": [_frontend_route_payload(route, manifest.id) for route in frontend.routes],
|
||||
"public_routes": [
|
||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items],
|
||||
"settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes],
|
||||
"view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
|
||||
"view_surfaces": _frontend_view_surfaces(manifest),
|
||||
}
|
||||
|
||||
|
||||
@@ -125,8 +165,8 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
"optional_dependencies": list(manifest.optional_dependencies),
|
||||
"enabled": True,
|
||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest.frontend),
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
|
||||
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
||||
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
|
||||
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
||||
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops", alias="ENABLED_MODULES")
|
||||
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,notifications,docs,ops", alias="ENABLED_MODULES")
|
||||
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
||||
|
||||
75
tests/test_automation_contract.py
Normal file
75
tests/test_automation_contract.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.automation import (
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
AutomationInvocation,
|
||||
AutomationPrincipalProvider,
|
||||
AutomationPrincipalRequest,
|
||||
AutomationPrincipalResolution,
|
||||
automation_principal_provider,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class _Provider:
|
||||
def resolve_automation_principal(self, session, *, request):
|
||||
del session
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=True,
|
||||
principal={"account_id": request.account_id},
|
||||
granted_scopes=request.grant_scopes,
|
||||
)
|
||||
|
||||
|
||||
class AutomationContractTests(unittest.TestCase):
|
||||
def test_invocation_records_stable_trigger_provenance(self) -> None:
|
||||
invocation = AutomationInvocation(
|
||||
kind="event",
|
||||
trigger_ref="dataflow-trigger:1",
|
||||
event_id="event-1",
|
||||
event_type="files.uploaded",
|
||||
)
|
||||
|
||||
self.assertEqual("event", invocation.kind)
|
||||
self.assertEqual("event-1", invocation.event_id)
|
||||
|
||||
def test_principal_provider_is_runtime_resolved(self) -> None:
|
||||
provider = _Provider()
|
||||
self.assertIsInstance(provider, AutomationPrincipalProvider)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="automation_contract_test",
|
||||
name="Automation contract test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER: (
|
||||
lambda context: provider
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
|
||||
self.assertIs(provider, automation_principal_provider(registry))
|
||||
result = provider.resolve_automation_principal(
|
||||
object(),
|
||||
request=AutomationPrincipalRequest(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
authorization_ref="trigger:1",
|
||||
grant_scopes=("dataflow:pipeline:run",),
|
||||
),
|
||||
)
|
||||
self.assertTrue(result.allowed)
|
||||
self.assertEqual(("dataflow:pipeline:run",), result.granted_scopes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
245
tests/test_credential_envelopes.py
Normal file
245
tests/test_credential_envelopes.py
Normal file
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.security.credential_envelopes import (
|
||||
CredentialAccessContext,
|
||||
CredentialEnvelope,
|
||||
CredentialEnvelopeError,
|
||||
create_credential_envelope,
|
||||
credential_envelope_summary,
|
||||
credential_visible_to_context,
|
||||
list_managed_credential_envelopes,
|
||||
resolve_credential_envelope,
|
||||
update_credential_envelope,
|
||||
)
|
||||
from govoplan_core.security.secrets import encrypt_secret
|
||||
from govoplan_core.server.credentials import _target_scope
|
||||
|
||||
|
||||
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
|
||||
class CredentialEnvelopeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
ChangeSequenceEntry.__table__.create(self.engine)
|
||||
CredentialEnvelope.__table__.create(self.engine)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.engine.dispose()
|
||||
|
||||
def test_inherited_credential_is_filtered_by_module_and_server(self) -> None:
|
||||
row = CredentialEnvelope(
|
||||
id="credential-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Shared account",
|
||||
credential_kind="username_password",
|
||||
public_data={"username": "service@example.org"},
|
||||
secret_data_encrypted=encrypt_secret(json.dumps({"password": "not-returned"})),
|
||||
secret_keys=["password"],
|
||||
allowed_modules=["mail"],
|
||||
allowed_server_refs=["mail:server-1"],
|
||||
inherit_to_lower_scopes=True,
|
||||
is_active=True,
|
||||
revision="revision-1",
|
||||
)
|
||||
allowed = CredentialAccessContext(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
target_scope_type="user",
|
||||
target_scope_id="user-1",
|
||||
module_id="mail",
|
||||
server_ref="mail:server-1",
|
||||
)
|
||||
wrong_module = CredentialAccessContext(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
target_scope_type="user",
|
||||
target_scope_id="user-1",
|
||||
module_id="calendar",
|
||||
server_ref="mail:server-1",
|
||||
)
|
||||
wrong_server = CredentialAccessContext(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
target_scope_type="user",
|
||||
target_scope_id="user-1",
|
||||
module_id="mail",
|
||||
server_ref="mail:server-2",
|
||||
)
|
||||
|
||||
self.assertTrue(credential_visible_to_context(row, allowed))
|
||||
self.assertFalse(credential_visible_to_context(row, wrong_module))
|
||||
self.assertFalse(credential_visible_to_context(row, wrong_server))
|
||||
self.assertNotIn("secret_data_encrypted", credential_envelope_summary(row))
|
||||
|
||||
def test_resolution_returns_secret_only_after_access_check(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
session.add(
|
||||
CredentialEnvelope(
|
||||
id="credential-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Shared account",
|
||||
credential_kind="username_password",
|
||||
public_data={"username": "service@example.org"},
|
||||
secret_data_encrypted=encrypt_secret(json.dumps({"password": "secret"})),
|
||||
secret_keys=["password"],
|
||||
allowed_modules=["mail"],
|
||||
allowed_server_refs=[],
|
||||
inherit_to_lower_scopes=True,
|
||||
is_active=True,
|
||||
revision="revision-1",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
resolved = resolve_credential_envelope(
|
||||
session,
|
||||
credential_id="credential-1",
|
||||
context=CredentialAccessContext(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
target_scope_type="user",
|
||||
target_scope_id="user-1",
|
||||
module_id="mail",
|
||||
),
|
||||
)
|
||||
self.assertEqual(resolved.public_data["username"], "service@example.org")
|
||||
self.assertEqual(resolved.secret_data["password"], "secret")
|
||||
|
||||
with self.assertRaises(CredentialEnvelopeError):
|
||||
resolve_credential_envelope(
|
||||
session,
|
||||
credential_id="credential-1",
|
||||
context=CredentialAccessContext(
|
||||
tenant_id="tenant-2",
|
||||
user_id="user-2",
|
||||
target_scope_type="user",
|
||||
target_scope_id="user-2",
|
||||
module_id="mail",
|
||||
),
|
||||
)
|
||||
|
||||
def test_management_listing_stays_in_tenant_but_ignores_use_site_limits(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
session.add_all(
|
||||
[
|
||||
CredentialEnvelope(
|
||||
id="tenant-credential",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Tenant restricted",
|
||||
credential_kind="username_password",
|
||||
public_data={},
|
||||
secret_keys=[],
|
||||
allowed_modules=["calendar"],
|
||||
allowed_server_refs=["calendar:source-1"],
|
||||
inherit_to_lower_scopes=True,
|
||||
is_active=True,
|
||||
revision="revision-1",
|
||||
),
|
||||
CredentialEnvelope(
|
||||
id="other-credential",
|
||||
tenant_id="tenant-2",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-2",
|
||||
name="Other tenant",
|
||||
credential_kind="username_password",
|
||||
public_data={},
|
||||
secret_keys=[],
|
||||
allowed_modules=[],
|
||||
allowed_server_refs=[],
|
||||
inherit_to_lower_scopes=False,
|
||||
is_active=True,
|
||||
revision="revision-2",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
rows = list_managed_credential_envelopes(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
self.assertEqual([row.id for row in rows], ["tenant-credential"])
|
||||
|
||||
def test_tenant_credential_permission_cannot_manage_system_scope(self) -> None:
|
||||
tenant_principal = _principal({"access:credential:write"})
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
_target_scope(
|
||||
tenant_principal,
|
||||
scope_type="system",
|
||||
scope_id=None,
|
||||
write=True,
|
||||
)
|
||||
self.assertEqual(raised.exception.status_code, 403)
|
||||
|
||||
system_principal = _principal({"access:system_credential:write"})
|
||||
self.assertEqual(
|
||||
_target_scope(
|
||||
system_principal,
|
||||
scope_type="system",
|
||||
scope_id=None,
|
||||
write=True,
|
||||
),
|
||||
(None, "system", None),
|
||||
)
|
||||
|
||||
def test_public_data_rejects_secrets_and_kind_changes_require_secret_decision(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
with self.assertRaisesRegex(CredentialEnvelopeError, "public_data"):
|
||||
create_credential_envelope(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Unsafe",
|
||||
credential_kind="username_password",
|
||||
public_data={"nested": [{"clientSecret": "not-public"}]},
|
||||
)
|
||||
|
||||
row = create_credential_envelope(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Safe",
|
||||
credential_kind="username_password",
|
||||
public_data={"username": "service@example.org"},
|
||||
secret_data={"password": "secret"},
|
||||
)
|
||||
with self.assertRaisesRegex(CredentialEnvelopeError, "requires replacing"):
|
||||
update_credential_envelope(
|
||||
session,
|
||||
row,
|
||||
credential_kind="token",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -232,7 +232,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(current, configured_migration_heads(url, migration_track="dev"))
|
||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
||||
self.assertIn("0f1e2d3c4b5a", current)
|
||||
self.assertIn("core_credential_envelopes", tables)
|
||||
self.assertIn("audit_outbox_events", tables)
|
||||
self.assertIn("calendar_outbox_operations", tables)
|
||||
self.assertIn("file_connector_profiles", tables)
|
||||
|
||||
76
tests/test_dataflow_contract.py
Normal file
76
tests/test_dataflow_contract.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.dataflows import (
|
||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||
DataflowRunDescriptor,
|
||||
DataflowRunLifecycleProvider,
|
||||
dataflow_run_lifecycle,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class _Provider:
|
||||
def start_run(self, session, principal, *, request):
|
||||
del session, principal
|
||||
return DataflowRunDescriptor(
|
||||
ref="dataflow-run:1",
|
||||
pipeline_ref=request.pipeline_ref,
|
||||
revision=request.revision,
|
||||
status="succeeded",
|
||||
definition_hash="abc",
|
||||
executor_version="test",
|
||||
)
|
||||
|
||||
def get_run(self, session, principal, *, run_ref):
|
||||
del session, principal
|
||||
if run_ref != "dataflow-run:1":
|
||||
return None
|
||||
return DataflowRunDescriptor(
|
||||
ref=run_ref,
|
||||
pipeline_ref="pipeline:1",
|
||||
revision=1,
|
||||
status="succeeded",
|
||||
definition_hash="abc",
|
||||
executor_version="test",
|
||||
)
|
||||
|
||||
def cancel_run(self, session, principal, *, run_ref):
|
||||
del session, principal
|
||||
return DataflowRunDescriptor(
|
||||
ref=run_ref,
|
||||
pipeline_ref="pipeline:1",
|
||||
revision=1,
|
||||
status="cancelled",
|
||||
definition_hash="abc",
|
||||
executor_version="test",
|
||||
)
|
||||
|
||||
|
||||
class DataflowContractTests(unittest.TestCase):
|
||||
def test_run_lifecycle_is_runtime_checkable_and_resolved(self) -> None:
|
||||
provider = _Provider()
|
||||
self.assertIsInstance(provider, DataflowRunLifecycleProvider)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="dataflow_contract_test",
|
||||
name="Dataflow contract test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE: lambda context: provider,
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
|
||||
self.assertIs(provider, dataflow_run_lifecycle(registry))
|
||||
self.assertIsNone(dataflow_run_lifecycle(PlatformRegistry()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
56
tests/test_dataflow_trigger_worker.py
Normal file
56
tests/test_dataflow_trigger_worker.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.celery_app import celery, dispatch_dataflow_triggers
|
||||
|
||||
|
||||
class DataflowTriggerWorkerTests(unittest.TestCase):
|
||||
def test_worker_commits_dispatch_outcome(self) -> None:
|
||||
session = MagicMock()
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
provider = MagicMock()
|
||||
provider.dispatch_due.return_value = {
|
||||
"queued": 1,
|
||||
"processed": 1,
|
||||
"succeeded": 1,
|
||||
"failed": 0,
|
||||
"blocked": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_core.celery_app._dataflow_trigger_dispatcher",
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
):
|
||||
result = dispatch_dataflow_triggers.run(25)
|
||||
|
||||
provider.dispatch_due.assert_called_once_with(session, limit=25)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(result["succeeded"], 1)
|
||||
|
||||
def test_worker_route_and_periodic_dispatch_are_registered(self) -> None:
|
||||
self.assertEqual(
|
||||
celery.conf.task_routes["govoplan.dataflow.dispatch_triggers"],
|
||||
{"queue": "dataflow"},
|
||||
)
|
||||
schedule = celery.conf.beat_schedule[
|
||||
"dataflow-triggers-every-minute"
|
||||
]
|
||||
self.assertEqual(
|
||||
schedule["task"],
|
||||
"govoplan.dataflow.dispatch_triggers",
|
||||
)
|
||||
self.assertEqual(schedule["schedule"], 60.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
186
tests/test_datasource_contract.py
Normal file
186
tests/test_datasource_contract.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.datasources import (
|
||||
CAPABILITY_DATASOURCE_CATALOGUE,
|
||||
CAPABILITY_DATASOURCE_LIFECYCLE,
|
||||
CAPABILITY_DATASOURCE_ORIGINS,
|
||||
CAPABILITY_DATASOURCE_PUBLICATION,
|
||||
DatasourceCatalogueProvider,
|
||||
DatasourceDescriptor,
|
||||
DatasourceField,
|
||||
DatasourceLifecycleProvider,
|
||||
DatasourceMaterialization,
|
||||
DatasourceOrigin,
|
||||
DatasourceOriginProvider,
|
||||
DatasourceOriginReadResult,
|
||||
DatasourcePublicationProvider,
|
||||
DatasourcePublicationResult,
|
||||
DatasourceReadResult,
|
||||
DatasourceStage,
|
||||
datasource_catalogue,
|
||||
datasource_lifecycle,
|
||||
datasource_origins,
|
||||
datasource_publication,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class _Provider:
|
||||
descriptor = DatasourceDescriptor(
|
||||
ref="datasource:source-1",
|
||||
source_name="monthly_cases",
|
||||
name="Monthly cases",
|
||||
kind="upload",
|
||||
mode="static",
|
||||
shape="tabular",
|
||||
schema=(DatasourceField(name="case_id", data_type="string", nullable=False),),
|
||||
fingerprint="abc123",
|
||||
)
|
||||
|
||||
def list_datasources(self, session, principal, *, query="", limit=100):
|
||||
del session, principal, query
|
||||
return (self.descriptor,)[:limit]
|
||||
|
||||
def get_datasource(self, session, principal, *, datasource_ref):
|
||||
del session, principal
|
||||
return self.descriptor if datasource_ref == self.descriptor.ref else None
|
||||
|
||||
def read_datasource(self, session, principal, *, request):
|
||||
del session, principal, request
|
||||
return DatasourceReadResult(
|
||||
datasource=self.descriptor,
|
||||
rows=({"case_id": "0012"},),
|
||||
total_rows=1,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
def list_materializations(self, session, principal, *, datasource_ref):
|
||||
del session, principal, datasource_ref
|
||||
return ()
|
||||
|
||||
def list_stages(self, session, principal, *, limit=100):
|
||||
del session, principal, limit
|
||||
return ()
|
||||
|
||||
def create_stage(self, session, principal, *, stage):
|
||||
del session, principal, stage
|
||||
return DatasourceStage(
|
||||
ref="stage:1",
|
||||
name="Stage",
|
||||
source_name="stage",
|
||||
kind="upload",
|
||||
mode="static",
|
||||
shape="tabular",
|
||||
state="ready",
|
||||
)
|
||||
|
||||
def promote_stage(self, session, principal, *, stage_ref, freeze=False, frozen_label=None):
|
||||
del session, principal, stage_ref, freeze, frozen_label
|
||||
return self.descriptor, DatasourceMaterialization(
|
||||
ref="materialization:1",
|
||||
datasource_ref=self.descriptor.ref,
|
||||
revision=1,
|
||||
state="published",
|
||||
fingerprint=self.descriptor.fingerprint,
|
||||
)
|
||||
|
||||
def register_origin(self, session, principal, **kwargs):
|
||||
del session, principal, kwargs
|
||||
return self.descriptor
|
||||
|
||||
def refresh_datasource(self, session, principal, *, datasource_ref):
|
||||
del session, principal, datasource_ref
|
||||
return self.promote_stage(object(), object(), stage_ref="stage:1")
|
||||
|
||||
def freeze_datasource(self, session, principal, *, datasource_ref, label=None):
|
||||
del session, principal, datasource_ref, label
|
||||
return self.promote_stage(object(), object(), stage_ref="stage:1")[1]
|
||||
|
||||
def retire_datasource(self, session, principal, *, datasource_ref):
|
||||
del session, principal, datasource_ref
|
||||
return self.descriptor
|
||||
|
||||
def publish_rows(self, session, principal, *, request):
|
||||
del session, principal, request
|
||||
return DatasourcePublicationResult(
|
||||
ref="publication:1",
|
||||
status="published",
|
||||
datasource=self.descriptor,
|
||||
materialization=DatasourceMaterialization(
|
||||
ref="materialization:1",
|
||||
datasource_ref=self.descriptor.ref,
|
||||
revision=1,
|
||||
state="published",
|
||||
fingerprint=self.descriptor.fingerprint,
|
||||
),
|
||||
)
|
||||
|
||||
def list_origins(self, session, principal, *, query="", limit=100):
|
||||
del session, principal, query
|
||||
return (
|
||||
DatasourceOrigin(
|
||||
ref="origin:1",
|
||||
source_name="origin",
|
||||
name="Origin",
|
||||
kind="database",
|
||||
shape="tabular",
|
||||
supported_modes=("live", "cached"),
|
||||
provider="test",
|
||||
),
|
||||
)[:limit]
|
||||
|
||||
def get_origin(self, session, principal, *, origin_ref):
|
||||
return self.list_origins(session, principal)[0] if origin_ref == "origin:1" else None
|
||||
|
||||
def read_origin(self, session, principal, *, request):
|
||||
origin = self.get_origin(session, principal, origin_ref=request.origin_ref)
|
||||
return DatasourceOriginReadResult(
|
||||
origin=origin,
|
||||
rows=({"id": 1},),
|
||||
total_rows=1,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
|
||||
class DatasourceContractTests(unittest.TestCase):
|
||||
def test_capabilities_are_runtime_checkable_and_resolved_without_modules(self) -> None:
|
||||
provider = _Provider()
|
||||
self.assertIsInstance(provider, DatasourceCatalogueProvider)
|
||||
self.assertIsInstance(provider, DatasourceLifecycleProvider)
|
||||
self.assertIsInstance(provider, DatasourcePublicationProvider)
|
||||
self.assertIsInstance(provider, DatasourceOriginProvider)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="datasource_contract_test",
|
||||
name="Datasource contract test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_DATASOURCE_CATALOGUE: lambda context: provider,
|
||||
CAPABILITY_DATASOURCE_LIFECYCLE: lambda context: provider,
|
||||
CAPABILITY_DATASOURCE_PUBLICATION: lambda context: provider,
|
||||
CAPABILITY_DATASOURCE_ORIGINS: lambda context: provider,
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
self.assertIs(provider, datasource_catalogue(registry))
|
||||
self.assertIs(provider, datasource_lifecycle(registry))
|
||||
self.assertIs(provider, datasource_publication(registry))
|
||||
self.assertIs(provider, datasource_origins(registry))
|
||||
self.assertIsNone(datasource_catalogue(PlatformRegistry()))
|
||||
|
||||
def test_descriptor_distinguishes_mode_kind_shape_and_materialization(self) -> None:
|
||||
descriptor = _Provider.descriptor
|
||||
|
||||
self.assertEqual("static", descriptor.mode)
|
||||
self.assertEqual("upload", descriptor.kind)
|
||||
self.assertEqual("tabular", descriptor.shape)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
79
tests/test_definition_governance_contract.py
Normal file
79
tests/test_definition_governance_contract.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||
DefinitionGovernancePolicy,
|
||||
DefinitionGovernanceRequest,
|
||||
DefinitionScopeRef,
|
||||
PolicyDecision,
|
||||
definition_governance_policy,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class _Policy:
|
||||
def resolve_definition_action(self, *, request):
|
||||
return PolicyDecision(
|
||||
allowed=request.definition_kind != "template"
|
||||
or request.action != "run",
|
||||
)
|
||||
|
||||
|
||||
class DefinitionGovernanceContractTests(unittest.TestCase):
|
||||
def test_scope_paths_reuse_policy_provenance_format(self) -> None:
|
||||
self.assertEqual("system", DefinitionScopeRef("system").path)
|
||||
self.assertEqual(
|
||||
"tenant:tenant-1",
|
||||
DefinitionScopeRef("tenant", "tenant-1").path,
|
||||
)
|
||||
|
||||
def test_policy_is_runtime_resolved(self) -> None:
|
||||
provider = _Policy()
|
||||
self.assertIsInstance(provider, DefinitionGovernancePolicy)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="definition_governance_test",
|
||||
name="Definition governance test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE: (
|
||||
lambda context: provider
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
|
||||
resolved = definition_governance_policy(registry)
|
||||
self.assertIs(provider, resolved)
|
||||
decision = resolved.resolve_definition_action(
|
||||
request=DefinitionGovernanceRequest(
|
||||
module_id="dataflow",
|
||||
definition_ref="pipeline:1",
|
||||
tenant_id="tenant-1",
|
||||
definition_scope=DefinitionScopeRef(
|
||||
"tenant",
|
||||
"tenant-1",
|
||||
),
|
||||
target_scope=DefinitionScopeRef("tenant", "tenant-1"),
|
||||
definition_kind="template",
|
||||
action="run",
|
||||
actor=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
)
|
||||
)
|
||||
self.assertFalse(decision.allowed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
145
tests/test_definition_graphs.py
Normal file
145
tests/test_definition_graphs.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.definition_graphs import (
|
||||
DefinitionEdge,
|
||||
DefinitionGraphConstraints,
|
||||
DefinitionGraphLibrary,
|
||||
DefinitionNode,
|
||||
DefinitionNodeCountConstraint,
|
||||
DefinitionNodeType,
|
||||
DefinitionPort,
|
||||
validate_definition_graph,
|
||||
)
|
||||
|
||||
|
||||
def library(*, allow_cycles: bool) -> DefinitionGraphLibrary:
|
||||
return DefinitionGraphLibrary(
|
||||
id="test",
|
||||
version="0.1.0",
|
||||
category_labels={"control": "Control"},
|
||||
node_types=(
|
||||
DefinitionNodeType(
|
||||
type="start",
|
||||
category="control",
|
||||
label="Start",
|
||||
description="Start",
|
||||
icon="play",
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="step",
|
||||
category="control",
|
||||
label="Step",
|
||||
description="Step",
|
||||
icon="square",
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="end",
|
||||
category="control",
|
||||
label="End",
|
||||
description="End",
|
||||
icon="circle-stop",
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
output_ports=(),
|
||||
),
|
||||
),
|
||||
constraints=DefinitionGraphConstraints(
|
||||
allow_cycles=allow_cycles,
|
||||
node_counts=(
|
||||
DefinitionNodeCountConstraint(
|
||||
code="graph.start_count",
|
||||
label="start",
|
||||
minimum=1,
|
||||
maximum=1,
|
||||
node_types=("start",),
|
||||
),
|
||||
DefinitionNodeCountConstraint(
|
||||
code="graph.end_count",
|
||||
label="end",
|
||||
minimum=1,
|
||||
node_types=("end",),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DefinitionGraphTests(unittest.TestCase):
|
||||
def test_library_driven_ports_and_counts_validate_a_definition(self) -> None:
|
||||
diagnostics = validate_definition_graph(
|
||||
library(allow_cycles=False),
|
||||
nodes=(
|
||||
DefinitionNode(id="start", type="start"),
|
||||
DefinitionNode(id="step", type="step"),
|
||||
DefinitionNode(id="end", type="end"),
|
||||
),
|
||||
edges=(
|
||||
DefinitionEdge(id="edge-1", source="start", target="step"),
|
||||
DefinitionEdge(id="edge-2", source="step", target="end"),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual((), diagnostics)
|
||||
|
||||
def test_constraints_change_cycle_semantics_without_changing_graph_shape(self) -> None:
|
||||
nodes = (
|
||||
DefinitionNode(id="start", type="start"),
|
||||
DefinitionNode(id="step", type="step"),
|
||||
DefinitionNode(id="retry", type="step"),
|
||||
DefinitionNode(id="end", type="end"),
|
||||
)
|
||||
edges = (
|
||||
DefinitionEdge(id="edge-1", source="start", target="step"),
|
||||
DefinitionEdge(id="edge-2", source="step", target="retry"),
|
||||
DefinitionEdge(id="edge-3", source="retry", target="step"),
|
||||
DefinitionEdge(id="edge-4", source="retry", target="end"),
|
||||
)
|
||||
|
||||
dag_codes = {
|
||||
item.code
|
||||
for item in validate_definition_graph(
|
||||
library(allow_cycles=False),
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
)
|
||||
}
|
||||
workflow_codes = {
|
||||
item.code
|
||||
for item in validate_definition_graph(
|
||||
library(allow_cycles=True),
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
)
|
||||
}
|
||||
|
||||
self.assertIn("graph.cycle", dag_codes)
|
||||
self.assertNotIn("graph.cycle", workflow_codes)
|
||||
|
||||
def test_unknown_ports_and_missing_required_inputs_are_explicit(self) -> None:
|
||||
diagnostics = validate_definition_graph(
|
||||
library(allow_cycles=False),
|
||||
nodes=(
|
||||
DefinitionNode(id="start", type="start"),
|
||||
DefinitionNode(id="end", type="end"),
|
||||
),
|
||||
edges=(
|
||||
DefinitionEdge(
|
||||
id="edge",
|
||||
source="start",
|
||||
target="end",
|
||||
target_port="missing",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
{"edge.unknown_target_port", "node.input_required"}.issubset(
|
||||
{item.code for item in diagnostics}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -917,9 +917,9 @@ finally:
|
||||
"module_id": "files",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-files",
|
||||
"python_ref": "govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4",
|
||||
"python_ref": "govoplan-files @ git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||
"webui_package": "@govoplan/files-webui",
|
||||
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.4",
|
||||
"webui_ref": "git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||
}])
|
||||
save_desired_enabled_modules(session, ("tenancy", "access", "files"))
|
||||
session.commit()
|
||||
@@ -931,8 +931,8 @@ finally:
|
||||
self.assertEqual(("files",), tuple(item.module_id for item in saved.items))
|
||||
self.assertEqual("manual", saved.items[0].source)
|
||||
self.assertEqual((
|
||||
"python -m pip install 'govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4'",
|
||||
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.4' && npm install",
|
||||
"python -m pip install 'govoplan-files @ git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git@v0.1.4'",
|
||||
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.1.4' && npm install",
|
||||
), module_install_plan_commands(saved))
|
||||
|
||||
def test_module_install_plan_preserves_catalog_source(self) -> None:
|
||||
@@ -961,7 +961,7 @@ finally:
|
||||
"python_package": "govoplan-files",
|
||||
"python_ref": "govoplan-files==0.2.0",
|
||||
"webui_package": "@govoplan/files-webui",
|
||||
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0",
|
||||
"webui_ref": "git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.2.0",
|
||||
"data_safety_acknowledged": True,
|
||||
})
|
||||
plan = ModuleInstallPlan(items=(item,))
|
||||
@@ -971,7 +971,7 @@ finally:
|
||||
self.assertTrue(item.as_dict()["data_safety_acknowledged"])
|
||||
self.assertEqual((
|
||||
"python -m pip install govoplan-files==0.2.0",
|
||||
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0' && npm install",
|
||||
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.2.0' && npm install",
|
||||
), module_install_plan_commands(plan))
|
||||
|
||||
def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None:
|
||||
@@ -996,7 +996,7 @@ finally:
|
||||
"python_package": "govoplan-files",
|
||||
"python_ref": "govoplan-files==0.1.6",
|
||||
"webui_package": "@govoplan/files-webui",
|
||||
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.6",
|
||||
"webui_ref": "git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.1.6",
|
||||
}],
|
||||
}), encoding="utf-8")
|
||||
|
||||
|
||||
117
tests/test_tabular_source_contract.py
Normal file
117
tests/test_tabular_source_contract.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.tabular_sources import (
|
||||
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER,
|
||||
CAPABILITY_CONNECTORS_TABULAR_SOURCES,
|
||||
TabularColumn,
|
||||
TabularReadRequest,
|
||||
TabularReadResult,
|
||||
TabularSnapshotInput,
|
||||
TabularSnapshotWriter,
|
||||
TabularSource,
|
||||
TabularSourceProvider,
|
||||
TabularSourceValidationError,
|
||||
parse_tabular_csv,
|
||||
tabular_snapshot_writer,
|
||||
tabular_source_provider,
|
||||
)
|
||||
|
||||
|
||||
class _TabularProvider:
|
||||
source = TabularSource(
|
||||
ref="snapshot:source-1",
|
||||
provider="snapshot",
|
||||
source_name="monthly_cases",
|
||||
name="Monthly cases",
|
||||
schema=(TabularColumn(name="case_id", data_type="string", nullable=False),),
|
||||
fingerprint="abc123",
|
||||
row_count=1,
|
||||
)
|
||||
|
||||
def list_sources(self, session, principal, *, query="", limit=100):
|
||||
del session, principal
|
||||
if query and query.casefold() not in self.source.name.casefold():
|
||||
return ()
|
||||
return (self.source,)[:limit]
|
||||
|
||||
def get_source(self, session, principal, *, source_ref):
|
||||
del session, principal
|
||||
return self.source if source_ref == self.source.ref else None
|
||||
|
||||
def read_source(self, session, principal, *, request):
|
||||
del session, principal
|
||||
rows = ({"case_id": "A-1"},)
|
||||
selected = rows[request.offset : request.offset + request.limit]
|
||||
return TabularReadResult(
|
||||
source=self.source,
|
||||
rows=selected,
|
||||
total_rows=len(rows),
|
||||
truncated=len(selected) < len(rows),
|
||||
)
|
||||
|
||||
def create_snapshot(self, session, principal, *, snapshot):
|
||||
del session, principal, snapshot
|
||||
return self.source
|
||||
|
||||
|
||||
class TabularSourceContractTests(unittest.TestCase):
|
||||
def test_provider_and_snapshot_writer_are_runtime_checkable(self) -> None:
|
||||
provider = _TabularProvider()
|
||||
|
||||
self.assertIsInstance(provider, TabularSourceProvider)
|
||||
self.assertIsInstance(provider, TabularSnapshotWriter)
|
||||
|
||||
def test_capabilities_resolve_without_importing_connectors(self) -> None:
|
||||
provider = _TabularProvider()
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="tabular_contract_test",
|
||||
name="Tabular contract test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_CONNECTORS_TABULAR_SOURCES: lambda context: provider,
|
||||
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER: lambda context: provider,
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
self.assertIs(provider, tabular_source_provider(registry))
|
||||
self.assertIs(provider, tabular_snapshot_writer(registry))
|
||||
self.assertIsNone(tabular_source_provider(PlatformRegistry()))
|
||||
|
||||
def test_read_and_snapshot_dtos_are_immutable_and_bounded_by_callers(self) -> None:
|
||||
provider = _TabularProvider()
|
||||
request = TabularReadRequest(source_ref=provider.source.ref, limit=1)
|
||||
result = provider.read_source(object(), object(), request=request)
|
||||
snapshot = TabularSnapshotInput(
|
||||
name="Monthly cases",
|
||||
source_name="monthly_cases",
|
||||
rows=({"case_id": "A-1"},),
|
||||
)
|
||||
|
||||
self.assertEqual(({"case_id": "A-1"},), result.rows)
|
||||
self.assertEqual(provider.source, provider.create_snapshot(object(), object(), snapshot=snapshot))
|
||||
|
||||
def test_shared_csv_parser_preserves_identifier_zeroes_and_rejects_extra_values(self) -> None:
|
||||
rows = parse_tabular_csv(
|
||||
"case_id;amount;active\n0012;7.5;true\n\n",
|
||||
delimiter=";",
|
||||
max_rows=2,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
({"case_id": "0012", "amount": 7.5, "active": True},),
|
||||
rows,
|
||||
)
|
||||
with self.assertRaises(TabularSourceValidationError):
|
||||
parse_tabular_csv("id,name\n1,Ada,extra\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
260
tests/test_view_surfaces.py
Normal file
260
tests/test_view_surfaces.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
)
|
||||
from govoplan_core.core.registry import (
|
||||
PlatformRegistry,
|
||||
RegistryError,
|
||||
manifest_view_surfaces,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.registry import available_module_manifests
|
||||
|
||||
|
||||
def example_manifest(
|
||||
*,
|
||||
custom_surfaces: tuple[ViewSurface, ...] = (),
|
||||
) -> ModuleManifest:
|
||||
return ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/example",
|
||||
label="Example",
|
||||
order=20,
|
||||
),
|
||||
),
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/example",
|
||||
component="ExamplePage",
|
||||
order=20,
|
||||
),
|
||||
),
|
||||
view_surfaces=custom_surfaces,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ViewSurfaceContractTests(unittest.TestCase):
|
||||
def test_manifest_derives_module_navigation_and_route_surfaces(self) -> None:
|
||||
surfaces = manifest_view_surfaces(example_manifest())
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"example.module",
|
||||
"example.nav.example",
|
||||
"example.route.example",
|
||||
],
|
||||
[surface.id for surface in surfaces],
|
||||
)
|
||||
self.assertEqual(
|
||||
"example.module",
|
||||
next(
|
||||
surface.parent_id
|
||||
for surface in surfaces
|
||||
if surface.id == "example.route.example"
|
||||
),
|
||||
)
|
||||
|
||||
def test_registry_includes_valid_custom_surface(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
example_manifest(
|
||||
custom_surfaces=(
|
||||
ViewSurface(
|
||||
id="example.settings.connection",
|
||||
module_id="example",
|
||||
kind="section",
|
||||
label="Connection settings",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
registry.validate()
|
||||
|
||||
custom_surface = next(
|
||||
surface
|
||||
for surface in registry.view_surfaces()
|
||||
if surface.id == "example.settings.connection"
|
||||
)
|
||||
self.assertEqual("example.module", custom_surface.parent_id)
|
||||
|
||||
def test_registry_rejects_surface_owned_by_another_module(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
example_manifest(
|
||||
custom_surfaces=(
|
||||
ViewSurface(
|
||||
id="other.settings.connection",
|
||||
module_id="other",
|
||||
kind="section",
|
||||
label="Connection settings",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "belongs to"):
|
||||
registry.validate()
|
||||
|
||||
def test_registry_rejects_unknown_surface_parent(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
example_manifest(
|
||||
custom_surfaces=(
|
||||
ViewSurface(
|
||||
id="example.settings.connection",
|
||||
module_id="example",
|
||||
kind="section",
|
||||
label="Connection settings",
|
||||
parent_id="example.missing",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "unknown parent"):
|
||||
registry.validate()
|
||||
|
||||
def test_registry_rejects_cyclic_surface_hierarchy(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
example_manifest(
|
||||
custom_surfaces=(
|
||||
ViewSurface(
|
||||
id="example.section.one",
|
||||
module_id="example",
|
||||
kind="section",
|
||||
label="One",
|
||||
parent_id="example.section.two",
|
||||
),
|
||||
ViewSurface(
|
||||
id="example.section.two",
|
||||
module_id="example",
|
||||
kind="section",
|
||||
label="Two",
|
||||
parent_id="example.section.one",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "contains a cycle"):
|
||||
registry.validate()
|
||||
|
||||
def test_required_surface_must_be_visible_by_default(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
example_manifest(
|
||||
custom_surfaces=(
|
||||
ViewSurface(
|
||||
id="example.recovery",
|
||||
module_id="example",
|
||||
kind="action",
|
||||
label="Recovery",
|
||||
required=True,
|
||||
default_visible=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "visible by default"):
|
||||
registry.validate()
|
||||
|
||||
def test_registry_validates_automatically_derived_surface_ids(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path=f"/{'segment-' * 30}",
|
||||
component="LongRoutePage",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "View surface id"):
|
||||
registry.validate()
|
||||
|
||||
def test_platform_module_payload_exposes_normalized_surface_contract(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
example_manifest(
|
||||
custom_surfaces=(
|
||||
ViewSurface(
|
||||
id="example.section.details",
|
||||
module_id="example",
|
||||
kind="section",
|
||||
label="Details",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
registry.validate()
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/modules")
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
frontend = response.json()["modules"][0]["frontend"]
|
||||
self.assertEqual("1", frontend["view_surface_contract_version"])
|
||||
self.assertEqual(
|
||||
"example.route.example",
|
||||
frontend["routes"][0]["surface_id"],
|
||||
)
|
||||
surfaces = {
|
||||
surface["id"]: surface
|
||||
for surface in frontend["view_surfaces"]
|
||||
}
|
||||
self.assertEqual(
|
||||
"example.module",
|
||||
surfaces["example.section.details"]["parent_id"],
|
||||
)
|
||||
|
||||
def test_access_admin_route_accepts_view_manager_permissions(self) -> None:
|
||||
access = available_module_manifests()["access"]
|
||||
admin_route = next(
|
||||
route
|
||||
for route in access.frontend.routes
|
||||
if route.path == "/admin"
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
{
|
||||
"views:definition:read",
|
||||
"views:assignment:read",
|
||||
"views:system_definition:read",
|
||||
"views:system_assignment:read",
|
||||
}.issubset(admin_route.required_any)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
365
webui/package-lock.json
generated
365
webui/package-lock.json
generated
@@ -15,6 +15,8 @@
|
||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
||||
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
|
||||
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
|
||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||
@@ -23,12 +25,15 @@
|
||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui"
|
||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -171,6 +176,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-dataflow/webui": {
|
||||
"name": "@govoplan/dataflow-webui",
|
||||
"version": "0.1.14",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-datasources/webui": {
|
||||
"name": "@govoplan/datasources-webui",
|
||||
"version": "0.1.14",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-docs/webui": {
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.10",
|
||||
@@ -339,6 +379,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-views/webui": {
|
||||
"name": "@govoplan/views-webui",
|
||||
"version": "0.1.0",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-workflow/webui": {
|
||||
"name": "@govoplan/workflow-webui",
|
||||
"version": "0.1.14",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -1091,6 +1165,14 @@
|
||||
"resolved": "../../govoplan-dashboard/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/dataflow-webui": {
|
||||
"resolved": "../../govoplan-dataflow/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/datasources-webui": {
|
||||
"resolved": "../../govoplan-datasources/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/docs-webui": {
|
||||
"resolved": "../../govoplan-docs/webui",
|
||||
"link": true
|
||||
@@ -1127,6 +1209,14 @@
|
||||
"resolved": "../../govoplan-scheduling/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/views-webui": {
|
||||
"resolved": "../../govoplan-views/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/workflow-webui": {
|
||||
"resolved": "../../govoplan-workflow/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1618,6 +1708,61 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1676,6 +1821,50 @@
|
||||
"node": ">=14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz",
|
||||
"integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.79",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=17",
|
||||
"@types/react-dom": ">=17",
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.79",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz",
|
||||
"integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.38",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
||||
@@ -1744,6 +1933,13 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1772,6 +1968,120 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1967,9 +2277,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2023,9 +2333,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2043,7 +2353,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -2294,6 +2604,16 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
@@ -2375,6 +2695,35 @@
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.14",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.11",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.8",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.8",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.8",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.8",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.11",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.8",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.8",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.8",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.8",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.10",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.8",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.8",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.2",
|
||||
@@ -721,7 +721,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/access-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#f2afcaa09c8201b141b6593f8a1ba574354e4db8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#f2afcaa09c8201b141b6593f8a1ba574354e4db8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -737,7 +737,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/admin-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#11ecf362a36d2244633194e55b0e9900669d72b7",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#11ecf362a36d2244633194e55b0e9900669d72b7",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -753,7 +753,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/audit-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#d3d2c60d7dc180cbd402cfeae38fa4f47f96969a",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#d3d2c60d7dc180cbd402cfeae38fa4f47f96969a",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -769,7 +769,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/calendar-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#9bcf41bb1fbb3d6dfc81adcd97d781ab411b695d",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#9bcf41bb1fbb3d6dfc81adcd97d781ab411b695d",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -788,7 +788,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/campaign-webui": {
|
||||
"version": "0.1.11",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#4774a8025cea3fd0962a23741e6d0241d4062663",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#4774a8025cea3fd0962a23741e6d0241d4062663",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
@@ -807,7 +807,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/dashboard-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#4b960ad37f0de0ef5224a0db22e95f84f0c9b3ca",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#4b960ad37f0de0ef5224a0db22e95f84f0c9b3ca",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -823,7 +823,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/docs-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#ec748e3fcfb96f52a99c004ec218058db73d01f6",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#ec748e3fcfb96f52a99c004ec218058db73d01f6",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -842,7 +842,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/files-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#8bf7296bf5b251c8309a5f0d023ff6428b31a353",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#8bf7296bf5b251c8309a5f0d023ff6428b31a353",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -861,7 +861,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/idm-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#b47f71916da8438f5f748d170b45f7b97fa3bfb3",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#b47f71916da8438f5f748d170b45f7b97fa3bfb3",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -879,10 +879,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/mail-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#5371cb3ad4b3882a3ffc377643183f66ab318619",
|
||||
"version": "0.1.10",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#3e23029090224fd01741242008e4991e0e548de1",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.10",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -896,7 +896,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/ops-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#341773a4ff8a76914a631e716e6f3fff2c0dc5ec",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#341773a4ff8a76914a631e716e6f3fff2c0dc5ec",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -915,7 +915,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/organizations-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#39c081c4fb8f37547c5f20b6a4fcad003c164b26",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#39c081c4fb8f37547c5f20b6a4fcad003c164b26",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -934,7 +934,7 @@
|
||||
},
|
||||
"node_modules/@govoplan/policy-webui": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#664eb38ab92c8ca2e755668eb125938c70ab27fb",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#664eb38ab92c8ca2e755668eb125938c70ab27fb",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"./app": {
|
||||
"types": "./src/app.ts",
|
||||
"import": "./src/app.ts"
|
||||
},
|
||||
"./definition-graph": {
|
||||
"types": "./src/definitionGraph.ts",
|
||||
"import": "./src/definitionGraph.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -26,7 +30,7 @@
|
||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
|
||||
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
|
||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
|
||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/definition-graph.test.js",
|
||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
|
||||
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
|
||||
@@ -41,6 +45,8 @@
|
||||
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
|
||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
|
||||
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
|
||||
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
@@ -50,12 +56,15 @@
|
||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui"
|
||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -22,19 +22,19 @@
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.11",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.8",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.8",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.8",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.8",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.8",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.8",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.8",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.8",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.10",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.11",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.8",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.8",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"lucide-react": "^1.23.0",
|
||||
|
||||
@@ -8,6 +8,8 @@ const packageByModule = {
|
||||
calendar: "@govoplan/calendar-webui",
|
||||
campaigns: "@govoplan/campaign-webui",
|
||||
dashboard: "@govoplan/dashboard-webui",
|
||||
dataflow: "@govoplan/dataflow-webui",
|
||||
datasources: "@govoplan/datasources-webui",
|
||||
docs: "@govoplan/docs-webui",
|
||||
files: "@govoplan/files-webui",
|
||||
idm: "@govoplan/idm-webui",
|
||||
@@ -16,7 +18,9 @@ const packageByModule = {
|
||||
organizations: "@govoplan/organizations-webui",
|
||||
ops: "@govoplan/ops-webui",
|
||||
policy: "@govoplan/policy-webui",
|
||||
scheduling: "@govoplan/scheduling-webui"
|
||||
scheduling: "@govoplan/scheduling-webui",
|
||||
views: "@govoplan/views-webui",
|
||||
workflow: "@govoplan/workflow-webui"
|
||||
};
|
||||
|
||||
const cases = [
|
||||
@@ -27,6 +31,13 @@ const cases = [
|
||||
{ name: "access-with-admin", modules: ["access", "admin"] },
|
||||
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
|
||||
{ name: "dashboard-only", modules: ["dashboard"] },
|
||||
{ name: "dataflow-only", modules: ["dataflow"] },
|
||||
{ name: "datasources-only", modules: ["datasources"] },
|
||||
{ name: "dataflow-with-datasources", modules: ["datasources", "dataflow"] },
|
||||
{ name: "workflow-only", modules: ["workflow"] },
|
||||
{ name: "workflow-with-dataflow", modules: ["datasources", "dataflow", "workflow"] },
|
||||
{ name: "views-only", modules: ["views"] },
|
||||
{ name: "views-with-administration", modules: ["access", "admin", "views"] },
|
||||
{ name: "calendar-only", modules: ["calendar"] },
|
||||
{ name: "files-only", modules: ["files"] },
|
||||
{ name: "mail-only", modules: ["mail"] },
|
||||
@@ -39,7 +50,7 @@ const cases = [
|
||||
{ name: "scheduling-only", modules: ["scheduling"] },
|
||||
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
|
||||
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
|
||||
];
|
||||
|
||||
const npmExec = process.env.npm_execpath;
|
||||
|
||||
@@ -3,16 +3,19 @@ import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences, ViewsRuntimeUiCapability } from "./types";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
import LoginModal from "./features/auth/LoginModal";
|
||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules, uiCapability } from "./platform/modules";
|
||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||
import { PlatformViewProvider } from "./platform/ViewContext";
|
||||
import { PLATFORM_VIEW_CHANGED_EVENT } from "./platform/views";
|
||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
||||
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
|
||||
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
@@ -37,18 +40,59 @@ export default function App() {
|
||||
const [backendReachable, setBackendReachable] = useState(true);
|
||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||
const [reloginMessage, setReloginMessage] = useState("");
|
||||
const [viewProjection, setViewProjection] = useState<EffectiveViewProjection | null>(null);
|
||||
|
||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
||||
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||
const viewsRuntime = useMemo(
|
||||
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
|
||||
[webModules]
|
||||
);
|
||||
const navItems = useMemo(
|
||||
() => navItemsForModules(webModules, viewProjection),
|
||||
[viewProjection, webModules]
|
||||
);
|
||||
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
||||
const contextModules = auth ? webModules : publicWebModules;
|
||||
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth || !viewsRuntime) {
|
||||
setViewProjection(null);
|
||||
return;
|
||||
}
|
||||
const currentAuth = auth;
|
||||
const currentRuntime = viewsRuntime;
|
||||
let cancelled = false;
|
||||
async function loadEffectiveView() {
|
||||
try {
|
||||
const projection = await currentRuntime.loadEffectiveView(settings, currentAuth);
|
||||
if (!cancelled) setViewProjection(projection);
|
||||
} catch (error) {
|
||||
if (!cancelled) setViewProjection(null);
|
||||
console.error("Failed to load the effective View", error);
|
||||
}
|
||||
}
|
||||
void loadEffectiveView();
|
||||
window.addEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
|
||||
};
|
||||
}, [
|
||||
auth?.user?.id,
|
||||
auth?.active_tenant?.id,
|
||||
auth?.tenant.id,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey,
|
||||
viewsRuntime
|
||||
]);
|
||||
|
||||
function updateSettings(next: ApiSettings) {
|
||||
setSettings(next);
|
||||
saveApiSettings(next);
|
||||
@@ -311,6 +355,7 @@ export default function App() {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<div className="public-landing">
|
||||
@@ -322,6 +367,7 @@ export default function App() {
|
||||
</div>
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformViewProvider>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
@@ -331,6 +377,7 @@ export default function App() {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||
@@ -347,12 +394,13 @@ export default function App() {
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformViewProvider>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
}
|
||||
|
||||
const defaultRoute = firstAccessibleRoute(auth, webModules);
|
||||
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
|
||||
const authAvailableLanguages = auth.available_languages?.map((item) => ({
|
||||
code: item.code,
|
||||
label: item.label,
|
||||
@@ -373,6 +421,7 @@ export default function App() {
|
||||
onLanguageChange={persistLanguagePreference}
|
||||
moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformViewProvider modules={webModules} projection={viewProjection}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||
@@ -392,7 +441,13 @@ export default function App() {
|
||||
path={route.path}
|
||||
element={
|
||||
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
|
||||
<ViewSurfaceRouteBoundary
|
||||
surfaceId={route.surfaceId}
|
||||
settings={settings}
|
||||
runtime={viewsRuntime}
|
||||
fallbackPath={defaultRoute}>
|
||||
{route.render({ settings, auth, onAuthChange: updateAuth })}
|
||||
</ViewSurfaceRouteBoundary>
|
||||
</PermissionBoundary>
|
||||
} />
|
||||
|
||||
@@ -412,6 +467,7 @@ export default function App() {
|
||||
}
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformViewProvider>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
|
||||
76
webui/src/api/credentials.ts
Normal file
76
webui/src/api/credentials.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { ApiSettings, CredentialEnvelopeSummary, MailProfileScope } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type CredentialEnvelopePayload = {
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
credential_kind: string;
|
||||
public_data?: Record<string, unknown>;
|
||||
secret_data?: Record<string, string>;
|
||||
allowed_modules?: string[];
|
||||
allowed_server_refs?: string[];
|
||||
inherit_to_lower_scopes?: boolean;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type CredentialEnvelopeUpdatePayload = Partial<
|
||||
Omit<CredentialEnvelopePayload, "scope_type" | "scope_id">
|
||||
> & {
|
||||
clear_secret?: boolean;
|
||||
};
|
||||
|
||||
export async function listCredentialEnvelopes(
|
||||
settings: ApiSettings,
|
||||
params: {
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
include_inactive?: boolean;
|
||||
}
|
||||
): Promise<CredentialEnvelopeSummary[]> {
|
||||
const search = new URLSearchParams({ scope_type: params.scope_type });
|
||||
if (params.scope_id) search.set("scope_id", params.scope_id);
|
||||
if (params.include_inactive) search.set("include_inactive", "true");
|
||||
const response = await apiFetch<{ credentials: CredentialEnvelopeSummary[] }>(
|
||||
settings,
|
||||
`/api/v1/credentials?${search.toString()}`
|
||||
);
|
||||
return response.credentials;
|
||||
}
|
||||
|
||||
export function createCredentialEnvelope(
|
||||
settings: ApiSettings,
|
||||
payload: CredentialEnvelopePayload
|
||||
): Promise<CredentialEnvelopeSummary> {
|
||||
return apiFetch<CredentialEnvelopeSummary>(settings, "/api/v1/credentials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCredentialEnvelope(
|
||||
settings: ApiSettings,
|
||||
credentialId: string,
|
||||
payload: CredentialEnvelopeUpdatePayload
|
||||
): Promise<CredentialEnvelopeSummary> {
|
||||
return apiFetch<CredentialEnvelopeSummary>(
|
||||
settings,
|
||||
`/api/v1/credentials/${encodeURIComponent(credentialId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCredentialEnvelope(
|
||||
settings: ApiSettings,
|
||||
credentialId: string
|
||||
): Promise<CredentialEnvelopeSummary> {
|
||||
return apiFetch<CredentialEnvelopeSummary>(
|
||||
settings,
|
||||
`/api/v1/credentials/${encodeURIComponent(credentialId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
MailCredentialEnvelope,
|
||||
MailImapTransportSettings,
|
||||
MailProfilePatternKey,
|
||||
MailProfilePolicy,
|
||||
@@ -11,11 +12,13 @@ import type {
|
||||
} from "../types";
|
||||
|
||||
export type {
|
||||
MailCredentialEnvelope,
|
||||
MailCredentialPolicy,
|
||||
MailProfilePatternKey,
|
||||
MailProfilePolicy,
|
||||
MailProfileScope,
|
||||
MailSecurity,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile
|
||||
} from "../types";
|
||||
|
||||
@@ -24,6 +27,7 @@ export type MailImapTestPayload = MailImapTransportSettings;
|
||||
export type MailTransportCredentialsPayload = MailTransportCredentials;
|
||||
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
|
||||
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
||||
export type MailCredentialListResponse = { credentials: MailCredentialEnvelope[] };
|
||||
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
||||
|
||||
export type MailConnectionTestResponse = {
|
||||
@@ -110,6 +114,7 @@ export type MailServerProfilePayload = {
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
is_active?: boolean;
|
||||
inherit_to_lower_scopes?: boolean;
|
||||
scope_type?: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
smtp: MailSmtpTestPayload;
|
||||
|
||||
526
webui/src/components/CredentialEnvelopeManager.tsx
Normal file
526
webui/src/components/CredentialEnvelopeManager.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
ApiSettings,
|
||||
CredentialEnvelopeSummary,
|
||||
MailProfileScope
|
||||
} from "../types";
|
||||
import {
|
||||
createCredentialEnvelope,
|
||||
deleteCredentialEnvelope,
|
||||
listCredentialEnvelopes,
|
||||
updateCredentialEnvelope
|
||||
} from "../api/credentials";
|
||||
import Button from "./Button";
|
||||
import Card from "./Card";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import ConnectionTree, { type ConnectionTreeColumn } from "./ConnectionTree";
|
||||
import Dialog from "./Dialog";
|
||||
import DismissibleAlert from "./DismissibleAlert";
|
||||
import FormField from "./FormField";
|
||||
import LoadingFrame from "./LoadingFrame";
|
||||
import PasswordField from "./PasswordField";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
import TableActionGroup from "./table/TableActionGroup";
|
||||
import ToggleSwitch from "./ToggleSwitch";
|
||||
import { useUnsavedDraftGuard } from "./UnsavedChangesGuard";
|
||||
|
||||
export type CredentialEnvelopeTargetOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary?: string | null;
|
||||
};
|
||||
|
||||
export type CredentialEnvelopeManagerProps = {
|
||||
settings: ApiSettings;
|
||||
scopeType: Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
|
||||
scopeId?: string | null;
|
||||
targetOptions?: CredentialEnvelopeTargetOption[];
|
||||
targetLabel?: string;
|
||||
title?: string;
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
type CredentialKind = "username_password" | "token" | "api_key";
|
||||
|
||||
type CredentialDraft = {
|
||||
name: string;
|
||||
description: string;
|
||||
credentialKind: CredentialKind;
|
||||
username: string;
|
||||
secret: string;
|
||||
allowedModules: string;
|
||||
allowedServerRefs: string;
|
||||
inheritToLowerScopes: boolean;
|
||||
isActive: boolean;
|
||||
clearSecret: boolean;
|
||||
retainedPublicData: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: CredentialDraft = {
|
||||
name: "",
|
||||
description: "",
|
||||
credentialKind: "username_password",
|
||||
username: "",
|
||||
secret: "",
|
||||
allowedModules: "",
|
||||
allowedServerRefs: "",
|
||||
inheritToLowerScopes: false,
|
||||
isActive: true,
|
||||
clearSecret: false,
|
||||
retainedPublicData: {}
|
||||
};
|
||||
|
||||
export default function CredentialEnvelopeManager({
|
||||
settings,
|
||||
scopeType,
|
||||
scopeId,
|
||||
targetOptions = [],
|
||||
targetLabel = "Target",
|
||||
title = "Credential envelopes",
|
||||
canWrite
|
||||
}: CredentialEnvelopeManagerProps) {
|
||||
const [selectedTargetId, setSelectedTargetId] = useState(scopeId ?? targetOptions[0]?.id ?? "");
|
||||
const [credentials, setCredentials] = useState<CredentialEnvelopeSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [editing, setEditing] = useState<CredentialEnvelopeSummary | "new" | null>(null);
|
||||
const [draft, setDraft] = useState<CredentialDraft>(EMPTY_DRAFT);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState("");
|
||||
const [deleting, setDeleting] = useState<CredentialEnvelopeSummary | null>(null);
|
||||
const activeScopeId = scopeType === "system" || scopeType === "tenant"
|
||||
? scopeId ?? null
|
||||
: selectedTargetId || null;
|
||||
const scopeReady = scopeType === "system" || scopeType === "tenant" || Boolean(activeScopeId);
|
||||
const draftDirty = Boolean(editing) && credentialDraftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: draftDirty,
|
||||
onSave: saveDraft,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (scopeId) {
|
||||
setSelectedTargetId(scopeId);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
targetOptions.length > 0 &&
|
||||
!targetOptions.some((option) => option.id === selectedTargetId)
|
||||
) {
|
||||
setSelectedTargetId(targetOptions[0].id);
|
||||
}
|
||||
}, [scopeId, selectedTargetId, targetOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCredentials();
|
||||
}, [
|
||||
activeScopeId,
|
||||
scopeReady,
|
||||
scopeType,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
async function loadCredentials() {
|
||||
if (!scopeReady) {
|
||||
setCredentials([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setCredentials(
|
||||
await listCredentialEnvelopes(settings, {
|
||||
scope_type: scopeType,
|
||||
scope_id: activeScopeId,
|
||||
include_inactive: true
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
setCredentials([]);
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
const next = { ...EMPTY_DRAFT, retainedPublicData: {} };
|
||||
setEditing("new");
|
||||
setDraft(next);
|
||||
setSavedDraftKey(credentialDraftKey(next));
|
||||
setError("");
|
||||
setNotice("");
|
||||
}
|
||||
|
||||
function openEdit(credential: CredentialEnvelopeSummary) {
|
||||
const next = credentialDraft(credential);
|
||||
setEditing(credential);
|
||||
setDraft(next);
|
||||
setSavedDraftKey(credentialDraftKey(next));
|
||||
setError("");
|
||||
setNotice("");
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (saving) return;
|
||||
setEditing(null);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
setSavedDraftKey("");
|
||||
}
|
||||
|
||||
async function saveDraft(): Promise<boolean> {
|
||||
if (!editing || !scopeReady || !draft.name.trim() || !canWrite) return false;
|
||||
if (editing === "new" && !draft.secret.trim()) {
|
||||
setError("Enter a secret before creating the credential.");
|
||||
return false;
|
||||
}
|
||||
const kindChanged = editing !== "new" && draft.credentialKind !== editing.credential_kind;
|
||||
if (kindChanged && !draft.secret.trim()) {
|
||||
setError("Enter a replacement secret when changing the credential type.");
|
||||
return false;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const publicData = {
|
||||
...draft.retainedPublicData,
|
||||
username: draft.username.trim() || undefined
|
||||
};
|
||||
if (!draft.username.trim()) delete publicData.username;
|
||||
const shared = {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim() || null,
|
||||
credential_kind: draft.credentialKind,
|
||||
public_data: publicData,
|
||||
allowed_modules: splitValues(draft.allowedModules),
|
||||
allowed_server_refs: splitValues(draft.allowedServerRefs),
|
||||
inherit_to_lower_scopes: draft.inheritToLowerScopes,
|
||||
is_active: draft.isActive
|
||||
};
|
||||
if (editing === "new") {
|
||||
await createCredentialEnvelope(settings, {
|
||||
scope_type: scopeType,
|
||||
scope_id: activeScopeId,
|
||||
...shared,
|
||||
secret_data: secretPayload(draft)
|
||||
});
|
||||
} else {
|
||||
await updateCredentialEnvelope(settings, editing.id, {
|
||||
...shared,
|
||||
...(draft.secret.trim() ? { secret_data: secretPayload(draft) } : {}),
|
||||
clear_secret: draft.clearSecret
|
||||
});
|
||||
}
|
||||
setNotice(editing === "new" ? "Credential created." : "Credential saved.");
|
||||
closeEditor();
|
||||
await loadCredentials();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleting) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteCredentialEnvelope(settings, deleting.id);
|
||||
setNotice("Credential deleted. Connections using it will remain configured but cannot authenticate.");
|
||||
setDeleting(null);
|
||||
await loadCredentials();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<ConnectionTreeColumn<CredentialEnvelopeSummary>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "credential",
|
||||
header: "Credential",
|
||||
width: "minmax(240px, 1.4fr)",
|
||||
render: (credential) => (
|
||||
<div className="connection-tree-main">
|
||||
<strong>{credential.name}</strong>
|
||||
<div className="connection-tree-muted-line">
|
||||
{credential.description || credential.public_data.username
|
||||
? String(credential.description || credential.public_data.username)
|
||||
: credential.id}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: "Type",
|
||||
width: "minmax(150px, 0.7fr)",
|
||||
render: (credential) => credentialKindLabel(credential.credential_kind)
|
||||
},
|
||||
{
|
||||
id: "availability",
|
||||
header: "Availability",
|
||||
width: "minmax(220px, 1fr)",
|
||||
render: (credential) => availabilityLabel(credential)
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: "120px",
|
||||
render: (credential) => (
|
||||
<StatusBadge
|
||||
status={credential.is_active && credential.secret_configured ? "success" : "inactive"}
|
||||
label={
|
||||
credential.is_active
|
||||
? credential.secret_configured
|
||||
? "Ready"
|
||||
: "No secret"
|
||||
: "Inactive"
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const kindChanged = editing !== null && editing !== "new" &&
|
||||
draft.credentialKind !== editing.credential_kind;
|
||||
const saveDisabled = saving || !canWrite || !draft.name.trim() ||
|
||||
(editing === "new" && !draft.secret.trim()) ||
|
||||
(kindChanged && !draft.secret.trim());
|
||||
|
||||
return (
|
||||
<div className="credential-envelope-manager">
|
||||
{targetOptions.length > 0 && (
|
||||
<FormField label={targetLabel}>
|
||||
<select
|
||||
value={selectedTargetId}
|
||||
disabled={saving}
|
||||
onChange={(event) => setSelectedTargetId(event.target.value)}
|
||||
>
|
||||
{targetOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label}{option.secondary ? ` - ${option.secondary}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
)}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||
<Card
|
||||
title={title}
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<Button
|
||||
type="button"
|
||||
title="Reload credentials"
|
||||
aria-label="Reload credentials"
|
||||
onClick={() => void loadCredentials()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={openCreate}
|
||||
disabled={!canWrite || !scopeReady || loading}
|
||||
>
|
||||
<Plus size={16} /> Add credential
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LoadingFrame loading={loading}>
|
||||
<ConnectionTree
|
||||
rows={credentials}
|
||||
columns={columns}
|
||||
getRowKey={(credential) => credential.id}
|
||||
emptyText="No credentials are configured for this scope."
|
||||
renderActions={(credential) => (
|
||||
<TableActionGroup
|
||||
actions={[
|
||||
{
|
||||
id: "edit",
|
||||
label: `Edit ${credential.name}`,
|
||||
icon: <Pencil size={15} />,
|
||||
onClick: () => openEdit(credential),
|
||||
disabled: !canWrite || saving
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: `Delete ${credential.name}`,
|
||||
icon: <Trash2 size={15} />,
|
||||
onClick: () => setDeleting(credential),
|
||||
disabled: !canWrite || saving,
|
||||
variant: "danger"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(editing)}
|
||||
title={editing === "new" ? "Add reusable credential" : "Edit reusable credential"}
|
||||
onClose={closeEditor}
|
||||
closeDisabled={saving}
|
||||
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={closeEditor} disabled={saving}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled}>
|
||||
<KeyRound size={16} /> {saving ? "Saving" : "Save credential"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="adaptive-config-form">
|
||||
<section className="adaptive-config-section">
|
||||
<header>
|
||||
<h3>Identity</h3>
|
||||
<p>The name and type are visible; secret values are never returned by the API.</p>
|
||||
</header>
|
||||
<div className="form-grid two">
|
||||
<FormField label="Name">
|
||||
<input value={draft.name} disabled={saving} onChange={(event) => setDraft({ ...draft, name: event.target.value })} autoFocus />
|
||||
</FormField>
|
||||
<FormField label="Type">
|
||||
<select value={draft.credentialKind} disabled={saving} onChange={(event) => setDraft({ ...draft, credentialKind: event.target.value as CredentialKind, secret: "", clearSecret: false })}>
|
||||
<option value="username_password">Username and password</option>
|
||||
<option value="token">Access token</option>
|
||||
<option value="api_key">API key</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<input value={draft.description} disabled={saving} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label={draft.credentialKind === "username_password" ? "Username" : "Account or key label"}>
|
||||
<input value={draft.username} disabled={saving} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={secretFieldLabel(draft.credentialKind)}
|
||||
help={editing !== "new" ? "Leave blank to retain the configured secret." : undefined}
|
||||
>
|
||||
<PasswordField value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" />
|
||||
</FormField>
|
||||
{editing !== "new" && (
|
||||
<ToggleSwitch
|
||||
checked={draft.clearSecret}
|
||||
disabled={saving || Boolean(draft.secret)}
|
||||
onChange={(clearSecret) => setDraft({ ...draft, clearSecret })}
|
||||
label="Remove configured secret"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="adaptive-config-section">
|
||||
<header>
|
||||
<h3>Availability</h3>
|
||||
<p>Empty module or server lists mean every module or server allowed by scope.</p>
|
||||
</header>
|
||||
<div className="form-grid two">
|
||||
<FormField label="Modules" help="Comma-separated module IDs, for example mail, files, calendar, addresses.">
|
||||
<input value={draft.allowedModules} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedModules: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Servers" help="Optional references such as mail:server-id, files:connection-id, calendar:source-id, or addresses:source-id.">
|
||||
<input value={draft.allowedServerRefs} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedServerRefs: event.target.value })} />
|
||||
</FormField>
|
||||
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
|
||||
<ToggleSwitch checked={draft.isActive} disabled={saving} onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
title="Delete credential"
|
||||
message={`Delete ${deleting?.name ?? "this credential"}? Connections that reference it will stop authenticating; the secret cannot be recovered.`}
|
||||
confirmLabel="Delete"
|
||||
tone="danger"
|
||||
busy={saving}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function credentialDraft(credential: CredentialEnvelopeSummary): CredentialDraft {
|
||||
return {
|
||||
...EMPTY_DRAFT,
|
||||
name: credential.name,
|
||||
description: credential.description ?? "",
|
||||
credentialKind: supportedCredentialKind(credential.credential_kind),
|
||||
username: String(credential.public_data.username ?? ""),
|
||||
allowedModules: credential.allowed_modules.join(", "),
|
||||
allowedServerRefs: credential.allowed_server_refs.join(", "),
|
||||
inheritToLowerScopes: credential.inherit_to_lower_scopes,
|
||||
isActive: credential.is_active,
|
||||
retainedPublicData: { ...credential.public_data }
|
||||
};
|
||||
}
|
||||
|
||||
function credentialDraftKey(draft: CredentialDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function splitValues(value: string): string[] {
|
||||
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function secretPayload(draft: CredentialDraft): Record<string, string> {
|
||||
const secret = draft.secret.trim();
|
||||
if (!secret) return {};
|
||||
if (draft.credentialKind === "token") return { token: secret };
|
||||
if (draft.credentialKind === "api_key") return { api_key: secret };
|
||||
return { password: secret };
|
||||
}
|
||||
|
||||
function supportedCredentialKind(value: string): CredentialKind {
|
||||
return value === "token" || value === "api_key" ? value : "username_password";
|
||||
}
|
||||
|
||||
function secretFieldLabel(kind: CredentialKind): string {
|
||||
if (kind === "token") return "Access token";
|
||||
if (kind === "api_key") return "API key";
|
||||
return "Password";
|
||||
}
|
||||
|
||||
function credentialKindLabel(kind: string): string {
|
||||
if (kind === "username_password") return "Username and password";
|
||||
if (kind === "api_key") return "API key";
|
||||
if (kind === "token") return "Access token";
|
||||
return kind.split("_").join(" ");
|
||||
}
|
||||
|
||||
function availabilityLabel(credential: CredentialEnvelopeSummary): string {
|
||||
const modules = credential.allowed_modules.length
|
||||
? credential.allowed_modules.join(", ")
|
||||
: "All modules";
|
||||
const servers = credential.allowed_server_refs.length
|
||||
? `${credential.allowed_server_refs.length} server restriction${credential.allowed_server_refs.length === 1 ? "" : "s"}`
|
||||
: "all servers";
|
||||
return `${modules} · ${servers}${credential.inherit_to_lower_scopes ? " · inherited" : ""}`;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
23
webui/src/components/PageScrollViewport.tsx
Normal file
23
webui/src/components/PageScrollViewport.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type PageScrollViewportProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"children"
|
||||
> & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function PageScrollViewport({
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: PageScrollViewportProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={["page-scroll-viewport", className].filter(Boolean).join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
webui/src/components/ViewSurfaceRouteBoundary.tsx
Normal file
61
webui/src/components/ViewSurfaceRouteBoundary.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { EyeOff } from "lucide-react";
|
||||
import type { ApiSettings, ViewsRuntimeUiCapability } from "../types";
|
||||
import { useEffectiveView, useViewSurfaceVisible } from "../platform/ViewContext";
|
||||
import { dispatchPlatformViewChanged } from "../platform/views";
|
||||
import { useGuardedNavigate } from "./UnsavedChangesGuard";
|
||||
import Button from "./Button";
|
||||
import Card from "./Card";
|
||||
import PageScrollViewport from "./PageScrollViewport";
|
||||
|
||||
export default function ViewSurfaceRouteBoundary({
|
||||
surfaceId,
|
||||
settings,
|
||||
runtime,
|
||||
fallbackPath,
|
||||
children
|
||||
}: {
|
||||
surfaceId?: string;
|
||||
settings: ApiSettings;
|
||||
runtime: ViewsRuntimeUiCapability | null;
|
||||
fallbackPath: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const visible = useViewSurfaceVisible(surfaceId);
|
||||
const projection = useEffectiveView();
|
||||
const navigate = useGuardedNavigate();
|
||||
|
||||
if (visible) return <>{children}</>;
|
||||
|
||||
async function exitView() {
|
||||
if (!runtime || projection?.locked) return;
|
||||
await runtime.activateView(settings, null);
|
||||
dispatchPlatformViewChanged();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad">
|
||||
<Card title="Outside the current view">
|
||||
<div className="empty-state">
|
||||
<EyeOff size={24} aria-hidden="true" />
|
||||
<p>
|
||||
This area is available to your account, but hidden by
|
||||
{projection?.activeViewName
|
||||
? ` the ${projection.activeViewName} view`
|
||||
: " the current view"}.
|
||||
</p>
|
||||
<div className="button-row">
|
||||
{!projection?.locked && runtime && (
|
||||
<Button variant="primary" onClick={() => void exitView()}>
|
||||
Exit view
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => navigate(fallbackPath)}>Back to view</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</PageScrollViewport>
|
||||
);
|
||||
}
|
||||
147
webui/src/definitionGraph.ts
Normal file
147
webui/src/definitionGraph.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
export type DefinitionGraphPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type DefinitionGraphPort = {
|
||||
id: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
multiple: boolean;
|
||||
minimum_connections: number;
|
||||
};
|
||||
|
||||
export type DefinitionGraphConfigField = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
required: boolean;
|
||||
description?: string | null;
|
||||
options: [string, string][];
|
||||
};
|
||||
|
||||
export type DefinitionGraphNodeType = {
|
||||
type: string;
|
||||
category: string;
|
||||
category_label: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
input_ports: DefinitionGraphPort[];
|
||||
output_ports: DefinitionGraphPort[];
|
||||
config_fields: DefinitionGraphConfigField[];
|
||||
default_config: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DefinitionGraphNode = {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
position: DefinitionGraphPosition;
|
||||
config: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DefinitionGraphEdge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
source_port?: string;
|
||||
target_port?: string;
|
||||
};
|
||||
|
||||
export type DefinitionGraph = {
|
||||
schema_version: number;
|
||||
nodes: DefinitionGraphNode[];
|
||||
edges: DefinitionGraphEdge[];
|
||||
};
|
||||
|
||||
export type DefinitionGraphConnection = {
|
||||
source: string;
|
||||
target: string;
|
||||
sourcePort?: string | null;
|
||||
targetPort?: string | null;
|
||||
};
|
||||
|
||||
export function createDefinitionGraphNode<TNode extends DefinitionGraphNode = DefinitionGraphNode>(
|
||||
type: string,
|
||||
position: DefinitionGraphPosition,
|
||||
library: DefinitionGraphNodeType[]
|
||||
): TNode {
|
||||
const definition = library.find((item) => item.type === type);
|
||||
return {
|
||||
id: `${type.replace(/\./g, "-")}-${crypto.randomUUID()}`,
|
||||
type,
|
||||
label: definition?.label ?? type,
|
||||
position,
|
||||
config: structuredClone(definition?.default_config ?? {})
|
||||
} as TNode;
|
||||
}
|
||||
|
||||
export function definitionConnectionError(
|
||||
graph: DefinitionGraph,
|
||||
library: DefinitionGraphNodeType[],
|
||||
connection: DefinitionGraphConnection,
|
||||
options: { allowCycles?: boolean } = {}
|
||||
): string | null {
|
||||
if (!connection.source || !connection.target) return "Both endpoints are required.";
|
||||
if (connection.source === connection.target) return "A node cannot connect to itself.";
|
||||
const source = graph.nodes.find((node) => node.id === connection.source);
|
||||
const target = graph.nodes.find((node) => node.id === connection.target);
|
||||
if (!source || !target) return "The connection references an unknown node.";
|
||||
const sourceDefinition = library.find((item) => item.type === source.type);
|
||||
const targetDefinition = library.find((item) => item.type === target.type);
|
||||
if (!sourceDefinition || !targetDefinition) return "The node type is not in this library.";
|
||||
const sourcePort = connection.sourcePort ?? "output";
|
||||
const targetPort = connection.targetPort ?? "input";
|
||||
if (!sourceDefinition.output_ports.some((port) => port.id === sourcePort)) {
|
||||
return "The source port is not available.";
|
||||
}
|
||||
const port = targetDefinition.input_ports.find((item) => item.id === targetPort);
|
||||
if (!port) return "The target port is not available.";
|
||||
if (graph.edges.some(
|
||||
(edge) =>
|
||||
edge.source === connection.source
|
||||
&& edge.target === connection.target
|
||||
&& (edge.source_port ?? "output") === sourcePort
|
||||
&& (edge.target_port ?? "input") === targetPort
|
||||
)) {
|
||||
return "This connection already exists.";
|
||||
}
|
||||
if (!port.multiple && graph.edges.some(
|
||||
(edge) =>
|
||||
edge.target === connection.target
|
||||
&& (edge.target_port ?? "input") === targetPort
|
||||
)) {
|
||||
return "The target port accepts only one connection.";
|
||||
}
|
||||
if (!options.allowCycles && wouldCreateDefinitionCycle(
|
||||
graph,
|
||||
connection.source,
|
||||
connection.target
|
||||
)) {
|
||||
return "This connection would create a cycle.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function wouldCreateDefinitionCycle(
|
||||
graph: DefinitionGraph,
|
||||
source: string,
|
||||
target: string
|
||||
): boolean {
|
||||
const outgoing = new Map<string, string[]>();
|
||||
graph.edges.forEach((edge) => {
|
||||
outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge.target]);
|
||||
});
|
||||
const pending = [target];
|
||||
const visited = new Set<string>();
|
||||
while (pending.length) {
|
||||
const nodeId = pending.pop();
|
||||
if (!nodeId || visited.has(nodeId)) continue;
|
||||
if (nodeId === source) return true;
|
||||
visited.add(nodeId);
|
||||
pending.push(...(outgoing.get(nodeId) ?? []));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import Card from "../components/Card";
|
||||
import PageScrollViewport from "../components/PageScrollViewport";
|
||||
|
||||
export default function PlaceholderPage({ title }: {title: string;}) {
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>{title}</h1>
|
||||
@@ -10,6 +12,7 @@ export default function PlaceholderPage({ title }: {title: string;}) {
|
||||
<Card>
|
||||
<p className="muted">i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade</p>
|
||||
</Card>
|
||||
</div>);
|
||||
</div>
|
||||
</PageScrollViewport>);
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Card from "../../components/Card";
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
import PageScrollViewport from "../../components/PageScrollViewport";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import { usePlatformModules } from "../../platform/ModuleContext";
|
||||
|
||||
@@ -7,6 +8,7 @@ export default function DashboardPage() {
|
||||
const modules = usePlatformModules();
|
||||
|
||||
return (
|
||||
<PageScrollViewport className="core-dashboard-page">
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
@@ -37,7 +39,8 @@ export default function DashboardPage() {
|
||||
<p className="muted">This minimal core home is only shown while no dashboard WebUI module is available. Feature modules own their pages and can expose dashboard widgets once the dashboard module is installed.</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>);
|
||||
</div>
|
||||
</PageScrollViewport>);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,11 @@ import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import SegmentedControl from "../../components/SegmentedControl";
|
||||
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { useEffectiveView, useViewSurfaces } from "../../platform/ViewContext";
|
||||
import { isViewSurfaceVisible } from "../../platform/views";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
|
||||
|
||||
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
|
||||
|
||||
@@ -33,14 +36,15 @@ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
|
||||
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
||||
];
|
||||
|
||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
|
||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, canUseCredentials: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
|
||||
const groups: ModuleSubnavGroup<SettingsSection>[] = [
|
||||
{
|
||||
title: "i18n:govoplan-core.account.f967543b",
|
||||
items: [
|
||||
{ id: "profile", label: "i18n:govoplan-core.my_profile.2f3df0a9" },
|
||||
...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "i18n:govoplan-core.mail_profiles.8a8018b7" }] : []),
|
||||
...(canUseFileConnectors ? [{ id: "file-connectors" as const, label: "i18n:govoplan-core.file_connections.1e362326" }] : [])]
|
||||
...(canUseFileConnectors ? [{ id: "file-connectors" as const, label: "i18n:govoplan-core.file_connections.1e362326" }] : []),
|
||||
...(canUseCredentials ? [{ id: "credentials" as const, label: "i18n:govoplan-core.credentials.dd097a22" }] : [])]
|
||||
|
||||
},
|
||||
{
|
||||
@@ -89,10 +93,12 @@ export default function SettingsPage({
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
|
||||
const effectiveView = useEffectiveView();
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
|
||||
const canUseMailProfiles = isViewSurfaceVisible(effectiveView, "mail.settings.profiles", viewSurfaces) && Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
|
||||
"mail_servers:read",
|
||||
"mail_servers:write",
|
||||
"mail_servers:manage_credentials",
|
||||
@@ -101,12 +107,26 @@ export default function SettingsPage({
|
||||
"admin:policies:read",
|
||||
"admin:policies:write"
|
||||
]);
|
||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||
const canUseFileConnectors = isViewSurfaceVisible(effectiveView, "files.settings.connectors", viewSurfaces) && Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||
const canUseCredentials = isViewSurfaceVisible(effectiveView, "access.settings.credentials", viewSurfaces) && hasAnyScope(auth, [
|
||||
"access:credential:read",
|
||||
"access:credential:write",
|
||||
"access:credential:manage_own",
|
||||
"mail:secret:manage_own",
|
||||
"admin:settings:read",
|
||||
"admin:settings:write"
|
||||
]);
|
||||
const contributedSections = useMemo(
|
||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||
[auth, settingsSectionCapabilities]
|
||||
() =>
|
||||
settingsSectionCapabilities
|
||||
.flatMap((capability) => capability.sections ?? [])
|
||||
.filter((section) => canUseSettingsContribution(auth, section))
|
||||
.filter((section) =>
|
||||
isViewSurfaceVisible(effectiveView, section.surfaceId, viewSurfaces)
|
||||
),
|
||||
[auth, effectiveView, settingsSectionCapabilities, viewSurfaces]
|
||||
);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, contributedSections), [canUseFileConnectors, canUseMailProfiles, contributedSections]);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, canUseCredentials, contributedSections), [canUseCredentials, canUseFileConnectors, canUseMailProfiles, contributedSections]);
|
||||
const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
|
||||
const requestedSection = searchParams.get("section");
|
||||
const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface";
|
||||
@@ -343,6 +363,21 @@ export default function SettingsPage({
|
||||
|
||||
}
|
||||
|
||||
{active === "credentials" &&
|
||||
<CredentialEnvelopeManager
|
||||
settings={settings}
|
||||
scopeType="user"
|
||||
scopeId={auth.user.id}
|
||||
title="My reusable credentials"
|
||||
canWrite={hasAnyScope(auth, [
|
||||
"access:credential:write",
|
||||
"access:credential:manage_own",
|
||||
"mail:secret:manage_own",
|
||||
"admin:settings:write"
|
||||
])} />
|
||||
|
||||
}
|
||||
|
||||
{active === "interface" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.interface_preferences.b82b39a7">
|
||||
@@ -505,6 +540,7 @@ function sectionOrder(sectionId: string, contributedSections: SettingsSectionCon
|
||||
profile: 10,
|
||||
"mail-profiles": 20,
|
||||
"file-connectors": 30,
|
||||
"credentials": 40,
|
||||
interface: 10,
|
||||
workspace: 20,
|
||||
"local-connection": 30
|
||||
|
||||
@@ -28,6 +28,8 @@ export * from "./api/resourceAccess";
|
||||
export * from "./platform/modules";
|
||||
export * from "./platform/ModuleContext";
|
||||
export * from "./platform/moduleEvents";
|
||||
export * from "./platform/ViewContext";
|
||||
export * from "./platform/views";
|
||||
|
||||
export * from "./utils/permissions";
|
||||
export * from "./utils/fieldHelp";
|
||||
@@ -57,11 +59,21 @@ export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/Con
|
||||
export { default as ColorPickerField } from "./components/ColorPickerField";
|
||||
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
|
||||
export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel";
|
||||
export { default as CredentialEnvelopeManager } from "./components/CredentialEnvelopeManager";
|
||||
export type { CredentialEnvelopeManagerProps, CredentialEnvelopeTargetOption } from "./components/CredentialEnvelopeManager";
|
||||
export {
|
||||
createCredentialEnvelope,
|
||||
deleteCredentialEnvelope,
|
||||
listCredentialEnvelopes,
|
||||
updateCredentialEnvelope
|
||||
} from "./api/credentials";
|
||||
export type { CredentialEnvelopePayload, CredentialEnvelopeUpdatePayload } from "./api/credentials";
|
||||
export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField";
|
||||
export { default as Dialog } from "./components/Dialog";
|
||||
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
|
||||
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
|
||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
||||
export { default as ViewSurfaceRouteBoundary } from "./components/ViewSurfaceRouteBoundary";
|
||||
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
||||
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
||||
export { default as FileDropZone } from "./components/FileDropZone";
|
||||
@@ -83,6 +95,8 @@ export { default as MetricCard } from "./components/MetricCard";
|
||||
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
|
||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||
export { default as PageTitle } from "./components/PageTitle";
|
||||
export { default as PageScrollViewport } from "./components/PageScrollViewport";
|
||||
export type { PageScrollViewportProps } from "./components/PageScrollViewport";
|
||||
export { default as PasswordField } from "./components/PasswordField";
|
||||
export { default as PeoplePicker } from "./components/people/PeoplePicker";
|
||||
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse } from "../types";
|
||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, ViewsRuntimeUiCapability } from "../types";
|
||||
import HelpMenu from "./HelpMenu";
|
||||
import LanguageMenu from "./LanguageMenu";
|
||||
import LoginModal from "../features/auth/LoginModal";
|
||||
@@ -10,7 +10,8 @@ import { apiFetch, isApiError } from "../api/client";
|
||||
import { logout, switchTenant } from "../api/auth";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { usePlatformModules } from "../platform/ModuleContext";
|
||||
import { usePlatformModules, usePlatformUiCapability } from "../platform/ModuleContext";
|
||||
import { useEffectiveView } from "../platform/ViewContext";
|
||||
|
||||
type NotificationSummary = {
|
||||
unread: number;
|
||||
@@ -39,6 +40,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
const tenantRef = useRef<HTMLDivElement>(null);
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const modules = usePlatformModules();
|
||||
const projection = useEffectiveView();
|
||||
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>("views.runtime");
|
||||
const ViewSelector = viewsRuntime?.Selector ?? null;
|
||||
|
||||
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||
@@ -218,6 +222,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
|
||||
<div className="titlebar-spacer" />
|
||||
|
||||
{auth && ViewSelector &&
|
||||
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||
}
|
||||
<LanguageMenu />
|
||||
<HelpMenu auth={auth} />
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import type { PlatformWebModule } from "../types";
|
||||
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
|
||||
import { useEffectiveView, useViewSurfaces } from "./ViewContext";
|
||||
import { isViewSurfaceVisible, moduleViewSurfaceId } from "./views";
|
||||
|
||||
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
|
||||
|
||||
@@ -17,9 +19,22 @@ export function usePlatformModuleInstalled(moduleId: string): boolean {
|
||||
}
|
||||
|
||||
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
|
||||
return uiCapability<T>(capabilityName, usePlatformModules());
|
||||
return uiCapability<T>(capabilityName, useViewVisibleModules());
|
||||
}
|
||||
|
||||
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
|
||||
return uiCapabilities<T>(capabilityName, usePlatformModules());
|
||||
return uiCapabilities<T>(capabilityName, useViewVisibleModules());
|
||||
}
|
||||
|
||||
function useViewVisibleModules(): PlatformWebModule[] {
|
||||
const modules = usePlatformModules();
|
||||
const projection = useEffectiveView();
|
||||
const surfaces = useViewSurfaces();
|
||||
return modules.filter((module) =>
|
||||
isViewSurfaceVisible(
|
||||
projection,
|
||||
moduleViewSurfaceId(module.id),
|
||||
surfaces
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
73
webui/src/platform/ViewContext.tsx
Normal file
73
webui/src/platform/ViewContext.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
||||
import type {
|
||||
EffectiveViewProjection,
|
||||
PlatformViewSurface,
|
||||
PlatformWebModule
|
||||
} from "../types";
|
||||
import {
|
||||
isViewSurfaceVisible,
|
||||
viewSurfaceCatalogueForModules
|
||||
} from "./views";
|
||||
|
||||
type ViewContextValue = {
|
||||
projection: EffectiveViewProjection | null;
|
||||
surfaces: PlatformViewSurface[];
|
||||
isVisible: (surfaceId: string | null | undefined) => boolean;
|
||||
};
|
||||
|
||||
const ViewContext = createContext<ViewContextValue>({
|
||||
projection: null,
|
||||
surfaces: [],
|
||||
isVisible: () => true
|
||||
});
|
||||
|
||||
export function PlatformViewProvider({
|
||||
modules,
|
||||
projection,
|
||||
children
|
||||
}: {
|
||||
modules: PlatformWebModule[];
|
||||
projection: EffectiveViewProjection | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const surfaces = useMemo(
|
||||
() => viewSurfaceCatalogueForModules(modules),
|
||||
[modules]
|
||||
);
|
||||
const value = useMemo<ViewContextValue>(
|
||||
() => ({
|
||||
projection,
|
||||
surfaces,
|
||||
isVisible: (surfaceId) =>
|
||||
isViewSurfaceVisible(projection, surfaceId, surfaces)
|
||||
}),
|
||||
[projection, surfaces]
|
||||
);
|
||||
return <ViewContext.Provider value={value}>{children}</ViewContext.Provider>;
|
||||
}
|
||||
|
||||
export function useEffectiveView(): EffectiveViewProjection | null {
|
||||
return useContext(ViewContext).projection;
|
||||
}
|
||||
|
||||
export function useViewSurfaces(): PlatformViewSurface[] {
|
||||
return useContext(ViewContext).surfaces;
|
||||
}
|
||||
|
||||
export function useViewSurfaceVisible(
|
||||
surfaceId: string | null | undefined
|
||||
): boolean {
|
||||
return useContext(ViewContext).isVisible(surfaceId);
|
||||
}
|
||||
|
||||
export function ViewSurfaceBoundary({
|
||||
surfaceId,
|
||||
children,
|
||||
fallback = null
|
||||
}: {
|
||||
surfaceId: string;
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}) {
|
||||
return useViewSurfaceVisible(surfaceId) ? <>{children}</> : <>{fallback}</>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
import { routeViewSurfaceId } from "./views";
|
||||
|
||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||
for (const module of modules) {
|
||||
@@ -30,7 +31,12 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
|
||||
}
|
||||
|
||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
return modules.flatMap((module) =>
|
||||
(module.routes ?? []).map((route) => ({
|
||||
...route,
|
||||
surfaceId: route.surfaceId ?? routeViewSurfaceId(module.id, route.path)
|
||||
}))
|
||||
).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
|
||||
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
|
||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
|
||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types";
|
||||
import {
|
||||
hasUiCapability as hasUiCapabilityForModules,
|
||||
moduleInstalled as moduleInstalledForModules,
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
uiCapability as uiCapabilityForModules } from
|
||||
"./moduleLogic";
|
||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||
import {
|
||||
isViewSurfaceVisible,
|
||||
navigationViewSurfaceId,
|
||||
routeViewSurfaceId,
|
||||
viewSurfaceCatalogueForModules
|
||||
} from "./views";
|
||||
|
||||
export const fallbackDashboardNavItem: PlatformNavItem =
|
||||
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
|
||||
@@ -49,6 +55,7 @@ const iconByName: Record<string, LucideIcon> = {
|
||||
campaign: Mails,
|
||||
"clipboard-pen-line": ClipboardPenLine,
|
||||
dashboard: LayoutDashboard,
|
||||
"database-zap": DatabaseZap,
|
||||
file: Folder,
|
||||
files: Folder,
|
||||
folder: Folder,
|
||||
@@ -61,7 +68,9 @@ const iconByName: Record<string, LucideIcon> = {
|
||||
"radio-tower": RadioTower,
|
||||
reports: ClipboardPenLine,
|
||||
templates: LayoutTemplate,
|
||||
users: Users
|
||||
users: Users,
|
||||
waypoints: Waypoints,
|
||||
workflow: WorkflowIcon
|
||||
};
|
||||
|
||||
export function iconComponentForName(iconName: string | null | undefined): LucideIcon | undefined {
|
||||
@@ -83,10 +92,65 @@ function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavIt
|
||||
iconName: item.icon ?? null,
|
||||
allOf: item.required_all,
|
||||
anyOf: item.required_any,
|
||||
order: item.order
|
||||
order: item.order,
|
||||
surfaceId: item.surface_id ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
function viewSurfaceFromMetadata(
|
||||
item: NonNullable<NonNullable<PlatformModuleInfo["frontend"]>["view_surfaces"]>[number]
|
||||
): PlatformViewSurface {
|
||||
return {
|
||||
id: item.id,
|
||||
moduleId: item.module_id,
|
||||
kind: item.kind,
|
||||
label: item.label,
|
||||
parentId: item.parent_id,
|
||||
description: item.description,
|
||||
order: item.order,
|
||||
defaultVisible: item.default_visible,
|
||||
required: item.required
|
||||
};
|
||||
}
|
||||
|
||||
function mergeViewSurfaces(
|
||||
module: PlatformWebModule,
|
||||
info: PlatformModuleInfo
|
||||
): PlatformViewSurface[] | undefined {
|
||||
const surfaces = new Map<string, PlatformViewSurface>();
|
||||
for (const surface of info.frontend?.view_surfaces ?? []) {
|
||||
const normalized = viewSurfaceFromMetadata(surface);
|
||||
surfaces.set(normalized.id, normalized);
|
||||
}
|
||||
for (const surface of module.viewSurfaces ?? []) {
|
||||
surfaces.set(surface.id, {
|
||||
...surfaces.get(surface.id),
|
||||
...surface,
|
||||
required: Boolean(surfaces.get(surface.id)?.required || surface.required)
|
||||
});
|
||||
}
|
||||
return surfaces.size ? [...surfaces.values()] : undefined;
|
||||
}
|
||||
|
||||
function routesWithServerMetadata(
|
||||
module: PlatformWebModule,
|
||||
info: PlatformModuleInfo
|
||||
) {
|
||||
const routesByPath = new Map(
|
||||
(info.frontend?.routes ?? []).map((route) => [route.path, route])
|
||||
);
|
||||
return (module.routes ?? []).map((route) => {
|
||||
const metadata = routesByPath.get(route.path);
|
||||
return {
|
||||
...route,
|
||||
surfaceId:
|
||||
metadata?.surface_id ??
|
||||
route.surfaceId ??
|
||||
routeViewSurfaceId(module.id, route.path)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function runtimeUiCapabilitiesForModule(module: PlatformWebModule, info: PlatformModuleInfo) {
|
||||
const enabledNames = new Set(info.runtime_ui_capabilities ?? []);
|
||||
@@ -110,7 +174,9 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
||||
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||
routes: routesWithServerMetadata(module, info),
|
||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||
viewSurfaces: mergeViewSurfaces(module, info),
|
||||
uiCapabilities: {
|
||||
...(module.uiCapabilities ?? {}),
|
||||
...runtimeUiCapabilitiesForModule(module, info)
|
||||
@@ -350,26 +416,42 @@ export function publicRouteContributionsForModules(modules: PlatformWebModule[])
|
||||
return publicRouteContributionsForModuleList(modules);
|
||||
}
|
||||
|
||||
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
||||
export function dashboardWidgetsForModules(
|
||||
modules: PlatformWebModule[] = localModules,
|
||||
projection?: EffectiveViewProjection | null
|
||||
): DashboardWidgetContribution[] {
|
||||
const catalogue = viewSurfaceCatalogueForModules(modules);
|
||||
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
||||
flatMap((capability) => capability.widgets ?? []).
|
||||
filter((widget) => isViewSurfaceVisible(projection, widget.surfaceId, catalogue)).
|
||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
||||
return [...shellNavItemsForModules(modules), ...modules.flatMap((module) => module.navItems ?? [])].
|
||||
export function navItemsForModules(
|
||||
modules: PlatformWebModule[],
|
||||
projection?: EffectiveViewProjection | null
|
||||
): PlatformNavItem[] {
|
||||
const catalogue = viewSurfaceCatalogueForModules(modules);
|
||||
const moduleItems = modules.flatMap((module) =>
|
||||
(module.navItems ?? []).map((item) => ({
|
||||
...item,
|
||||
surfaceId: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to)
|
||||
}))
|
||||
);
|
||||
return [...shellNavItemsForModules(modules), ...moduleItems].
|
||||
map(resolveNavItemIcon).
|
||||
filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)).
|
||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules): PlatformNavItem[] {
|
||||
return navItemsForModules(modules).filter((item) => {
|
||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): PlatformNavItem[] {
|
||||
return navItemsForModules(modules, projection).filter((item) => {
|
||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules): string {
|
||||
return visibleNavItems(auth, modules)[0]?.to ?? "/dashboard";
|
||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): string {
|
||||
return visibleNavItems(auth, modules, projection)[0]?.to ?? "/dashboard";
|
||||
}
|
||||
|
||||
136
webui/src/platform/views.ts
Normal file
136
webui/src/platform/views.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type {
|
||||
EffectiveViewProjection,
|
||||
PlatformRouteContribution,
|
||||
PlatformViewSurface,
|
||||
PlatformWebModule
|
||||
} from "../types";
|
||||
|
||||
export const PLATFORM_VIEW_CHANGED_EVENT = "govoplan:view-changed";
|
||||
|
||||
export function moduleViewSurfaceId(moduleId: string): string {
|
||||
return `${moduleId}.module`;
|
||||
}
|
||||
|
||||
export function navigationViewSurfaceId(moduleId: string, path: string): string {
|
||||
return `${moduleId}.nav.${surfaceSlug(path)}`;
|
||||
}
|
||||
|
||||
export function routeViewSurfaceId(moduleId: string, path: string): string {
|
||||
return `${moduleId}.route.${surfaceSlug(path)}`;
|
||||
}
|
||||
|
||||
export function viewSurfaceCatalogueForModules(
|
||||
modules: PlatformWebModule[]
|
||||
): PlatformViewSurface[] {
|
||||
const surfaces = new Map<string, PlatformViewSurface>();
|
||||
for (const module of modules) {
|
||||
const rootId = moduleViewSurfaceId(module.id);
|
||||
addSurface(surfaces, {
|
||||
id: rootId,
|
||||
moduleId: module.id,
|
||||
kind: "module",
|
||||
label: module.label,
|
||||
order: Math.min(
|
||||
...(module.navItems?.map((item) => item.order ?? 100) ?? [100])
|
||||
)
|
||||
});
|
||||
for (const item of module.navItems ?? []) {
|
||||
addSurface(surfaces, {
|
||||
id: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to),
|
||||
moduleId: module.id,
|
||||
kind: "navigation",
|
||||
label: item.label,
|
||||
parentId: rootId,
|
||||
order: item.order
|
||||
});
|
||||
}
|
||||
for (const route of module.routes ?? []) {
|
||||
addSurface(surfaces, {
|
||||
id: route.surfaceId ?? routeViewSurfaceId(module.id, route.path),
|
||||
moduleId: module.id,
|
||||
kind: "route",
|
||||
label: route.path,
|
||||
description: route.path,
|
||||
parentId: rootId,
|
||||
order: route.order
|
||||
});
|
||||
}
|
||||
for (const surface of module.viewSurfaces ?? []) {
|
||||
addSurface(surfaces, {
|
||||
...surface,
|
||||
parentId:
|
||||
surface.id === rootId
|
||||
? null
|
||||
: surface.parentId ?? rootId
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...surfaces.values()].sort(
|
||||
(left, right) =>
|
||||
(left.order ?? 100) - (right.order ?? 100) ||
|
||||
left.label.localeCompare(right.label)
|
||||
);
|
||||
}
|
||||
|
||||
export function isViewSurfaceVisible(
|
||||
projection: EffectiveViewProjection | null | undefined,
|
||||
surfaceId: string | null | undefined,
|
||||
catalogue: PlatformViewSurface[]
|
||||
): boolean {
|
||||
if (!surfaceId || !projection?.activeViewId) return true;
|
||||
const byId = new Map(catalogue.map((surface) => [surface.id, surface]));
|
||||
const surface = byId.get(surfaceId);
|
||||
if (!surface) return true;
|
||||
const visible = new Set(projection.visibleSurfaceIds);
|
||||
let current: PlatformViewSurface | undefined = surface;
|
||||
const visited = new Set<string>();
|
||||
while (current) {
|
||||
if (visited.has(current.id)) return false;
|
||||
visited.add(current.id);
|
||||
if (!current.required && !visible.has(current.id)) return false;
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function visibleRoutesForProjection(
|
||||
modules: PlatformWebModule[],
|
||||
projection: EffectiveViewProjection | null | undefined
|
||||
): PlatformRouteContribution[] {
|
||||
const catalogue = viewSurfaceCatalogueForModules(modules);
|
||||
return modules.flatMap((module) =>
|
||||
(module.routes ?? []).filter((route) =>
|
||||
isViewSurfaceVisible(
|
||||
projection,
|
||||
route.surfaceId ?? routeViewSurfaceId(module.id, route.path),
|
||||
catalogue
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function dispatchPlatformViewChanged(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(PLATFORM_VIEW_CHANGED_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
function addSurface(
|
||||
target: Map<string, PlatformViewSurface>,
|
||||
surface: PlatformViewSurface
|
||||
): void {
|
||||
const existing = target.get(surface.id);
|
||||
if (!existing) {
|
||||
target.set(surface.id, surface);
|
||||
return;
|
||||
}
|
||||
target.set(surface.id, {
|
||||
...existing,
|
||||
...surface,
|
||||
required: Boolean(existing.required || surface.required)
|
||||
});
|
||||
}
|
||||
|
||||
function surfaceSlug(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "") || "root";
|
||||
}
|
||||
@@ -68,8 +68,10 @@
|
||||
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
|
||||
.section-link.subtle { font-size: 13px; }
|
||||
.workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; }
|
||||
.page-scroll-viewport { width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: auto; }
|
||||
.ui-no-sticky-section-sidebars .app-content { overflow: auto; }
|
||||
.ui-no-sticky-section-sidebars .workspace { height: auto; min-height: 100%; }
|
||||
.ui-no-sticky-section-sidebars .page-scroll-viewport { height: auto; min-height: 100%; overflow: visible; }
|
||||
.ui-no-sticky-section-sidebars .section-sidebar {
|
||||
height: max-content;
|
||||
max-height: none;
|
||||
@@ -125,7 +127,10 @@
|
||||
.step-intro p { margin-top: 6px; color: var(--muted); }
|
||||
.button-row { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; }
|
||||
.alert { padding: 14px 16px; border-radius: var(--radius-sm); margin-bottom: 16px; }
|
||||
.alert.warning { background: var(--warning-bg); } .alert.danger { background: var(--danger-bg); }
|
||||
.alert.success { background: var(--success-bg); color: var(--success-text); }
|
||||
.alert.info { background: var(--info-bg); color: var(--info-text); }
|
||||
.alert.warning { background: var(--warning-bg); color: var(--warning-text); }
|
||||
.alert.danger { background: var(--danger-bg); color: var(--danger-text); }
|
||||
.table-like { border: var(--border-line); }
|
||||
.table-row-link { display: grid; grid-template-columns: 1fr auto; text-decoration: none; color: var(--text); padding: 14px 16px; border-bottom: var(--border-line); }
|
||||
.table-row-link:hover { background: var(--panel-soft); }
|
||||
|
||||
@@ -227,6 +227,26 @@ export type CampaignListItem = {
|
||||
|
||||
export type PlatformIconName = string;
|
||||
|
||||
export type PlatformViewSurfaceKind =
|
||||
| "module"
|
||||
| "navigation"
|
||||
| "route"
|
||||
| "section"
|
||||
| "action"
|
||||
| "selector";
|
||||
|
||||
export type PlatformViewSurface = {
|
||||
id: string;
|
||||
moduleId: string;
|
||||
kind: PlatformViewSurfaceKind;
|
||||
label: string;
|
||||
parentId?: string | null;
|
||||
description?: string | null;
|
||||
order?: number;
|
||||
defaultVisible?: boolean;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type PlatformNavItem = {
|
||||
to: string;
|
||||
label: string;
|
||||
@@ -237,6 +257,7 @@ export type PlatformNavItem = {
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
order?: number;
|
||||
surfaceId?: string;
|
||||
};
|
||||
|
||||
export type PlatformRouteContext = {
|
||||
@@ -266,6 +287,7 @@ export type AdminSectionContribution = {
|
||||
order?: number;
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
surfaceId?: string;
|
||||
render: (context: AdminSectionRenderContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -289,6 +311,7 @@ export type SettingsSectionContribution = {
|
||||
order?: number;
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
surfaceId?: string;
|
||||
render: (context: SettingsSectionRenderContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -301,6 +324,7 @@ export type PlatformRouteContribution = {
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
order?: number;
|
||||
surfaceId?: string;
|
||||
render: (context: PlatformRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -333,6 +357,53 @@ export type PlatformWebModule = {
|
||||
translations?: PlatformTranslations;
|
||||
uiCapabilities?: PlatformUiCapabilities;
|
||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||
viewSurfaces?: PlatformViewSurface[];
|
||||
};
|
||||
|
||||
export type EffectiveViewOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
revisionId: string;
|
||||
};
|
||||
|
||||
export type EffectiveViewProjection = {
|
||||
activeViewId: string | null;
|
||||
activeRevisionId: string | null;
|
||||
activeViewName: string | null;
|
||||
visibleSurfaceIds: string[];
|
||||
locked: boolean;
|
||||
availableViews: EffectiveViewOption[];
|
||||
provenance: Array<{
|
||||
source: string;
|
||||
scopeType?: string | null;
|
||||
scopeId?: string | null;
|
||||
detail?: string | null;
|
||||
}>;
|
||||
diagnostics: Array<{
|
||||
severity: "warning" | "error";
|
||||
code: string;
|
||||
message: string;
|
||||
surfaceIds: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ViewSelectorProps = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
projection: EffectiveViewProjection | null;
|
||||
};
|
||||
|
||||
export type ViewsRuntimeUiCapability = {
|
||||
loadEffectiveView: (
|
||||
settings: ApiSettings,
|
||||
auth: AuthInfo
|
||||
) => Promise<EffectiveViewProjection>;
|
||||
activateView: (
|
||||
settings: ApiSettings,
|
||||
viewId: string | null
|
||||
) => Promise<EffectiveViewProjection>;
|
||||
Selector?: ComponentType<ViewSelectorProps>;
|
||||
};
|
||||
|
||||
export type DashboardWidgetSize = "small" | "medium" | "wide" | "full";
|
||||
@@ -353,6 +424,7 @@ export type DashboardWidgetContribution = {
|
||||
moduleId?: string;
|
||||
category?: string;
|
||||
order?: number;
|
||||
surfaceId?: string;
|
||||
defaultVisible?: boolean;
|
||||
defaultSize?: DashboardWidgetSize;
|
||||
supportedSizes?: DashboardWidgetSize[];
|
||||
@@ -383,6 +455,7 @@ export type OrganizationFunctionActionContribution = {
|
||||
order?: number;
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
surfaceId?: string;
|
||||
onClick: (context: OrganizationFunctionActionContext) => void;
|
||||
};
|
||||
|
||||
@@ -464,6 +537,51 @@ export type MailServerProfileCredentials = {
|
||||
imap?: MailTransportCredentials | null;
|
||||
};
|
||||
|
||||
export type CredentialEnvelopeSummary = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
credential_kind: string;
|
||||
public_data: Record<string, unknown>;
|
||||
secret_keys: string[];
|
||||
secret_configured: boolean;
|
||||
allowed_modules: string[];
|
||||
allowed_server_refs: string[];
|
||||
inherit_to_lower_scopes: boolean;
|
||||
is_active: boolean;
|
||||
revision: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
};
|
||||
|
||||
export type MailCredentialEnvelope = CredentialEnvelopeSummary & {
|
||||
binding_id?: string | null;
|
||||
server_id?: string | null;
|
||||
is_default: boolean;
|
||||
};
|
||||
|
||||
export type MailServerEndpoint = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
tenant_id?: string | null;
|
||||
protocol: "smtp" | "imap";
|
||||
name: string;
|
||||
config: MailTransportSettings | MailImapTransportSettings;
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
inherit_to_lower_scopes: boolean;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
transport_revision: string;
|
||||
credentials: MailCredentialEnvelope[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type MailServerProfile = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
@@ -473,11 +591,13 @@ export type MailServerProfile = {
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
is_active: boolean;
|
||||
inherit_to_lower_scopes?: boolean;
|
||||
smtp: MailTransportSettings;
|
||||
imap?: MailImapTransportSettings | null;
|
||||
credentials?: MailServerProfileCredentials | null;
|
||||
smtp_password_configured?: boolean;
|
||||
imap_password_configured?: boolean;
|
||||
servers?: MailServerEndpoint[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
@@ -753,6 +873,7 @@ export type PlatformFrontendRouteInfo = {
|
||||
required_all: string[];
|
||||
required_any: string[];
|
||||
order: number;
|
||||
surface_id?: string | null;
|
||||
};
|
||||
|
||||
export type PlatformFrontendModuleInfo = {
|
||||
@@ -777,8 +898,21 @@ export type PlatformFrontendModuleInfo = {
|
||||
required_all: string[];
|
||||
required_any: string[];
|
||||
order: number;
|
||||
surface_id?: string | null;
|
||||
}>;
|
||||
settings_routes: PlatformFrontendRouteInfo[];
|
||||
view_surface_contract_version?: string | null;
|
||||
view_surfaces?: Array<{
|
||||
id: string;
|
||||
module_id: string;
|
||||
kind: PlatformViewSurfaceKind;
|
||||
label: string;
|
||||
parent_id?: string | null;
|
||||
description?: string | null;
|
||||
order: number;
|
||||
default_visible: boolean;
|
||||
required: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PlatformPublicModuleInfo = {
|
||||
@@ -814,6 +948,7 @@ export type PlatformModuleInfo = {
|
||||
required_all: string[];
|
||||
required_any: string[];
|
||||
order: number;
|
||||
surface_id?: string | null;
|
||||
}>;
|
||||
frontend?: PlatformFrontendModuleInfo | null;
|
||||
};
|
||||
|
||||
@@ -161,5 +161,11 @@ export const adminReadScopes = [
|
||||
"access:system_role:read",
|
||||
"access:audit:read",
|
||||
"access:system_setting:read",
|
||||
"access:governance:read"
|
||||
"access:system_credential:read",
|
||||
"access:credential:read",
|
||||
"access:governance:read",
|
||||
"views:definition:read",
|
||||
"views:assignment:read",
|
||||
"views:system_definition:read",
|
||||
"views:system_assignment:read"
|
||||
];
|
||||
|
||||
68
webui/tests/definition-graph.test.ts
Normal file
68
webui/tests/definition-graph.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
createDefinitionGraphNode,
|
||||
definitionConnectionError,
|
||||
type DefinitionGraph,
|
||||
type DefinitionGraphNodeType
|
||||
} from "../src/definitionGraph";
|
||||
|
||||
const library: DefinitionGraphNodeType[] = [
|
||||
{
|
||||
type: "start",
|
||||
category: "control",
|
||||
category_label: "Control",
|
||||
label: "Start",
|
||||
description: "Start",
|
||||
icon: "play",
|
||||
input_ports: [],
|
||||
output_ports: [
|
||||
{ id: "output", label: "Output", required: true, multiple: false, minimum_connections: 1 }
|
||||
],
|
||||
config_fields: [],
|
||||
default_config: { mode: "manual" }
|
||||
},
|
||||
{
|
||||
type: "end",
|
||||
category: "control",
|
||||
category_label: "Control",
|
||||
label: "End",
|
||||
description: "End",
|
||||
icon: "circle-stop",
|
||||
input_ports: [
|
||||
{ id: "input", label: "Input", required: true, multiple: false, minimum_connections: 1 }
|
||||
],
|
||||
output_ports: [],
|
||||
config_fields: [],
|
||||
default_config: {}
|
||||
}
|
||||
];
|
||||
|
||||
const graph: DefinitionGraph = {
|
||||
schema_version: 1,
|
||||
nodes: [
|
||||
{ id: "start", type: "start", label: "Start", position: { x: 0, y: 0 }, config: {} },
|
||||
{ id: "end", type: "end", label: "End", position: { x: 100, y: 0 }, config: {} }
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
if (definitionConnectionError(
|
||||
graph,
|
||||
library,
|
||||
{ source: "start", target: "end" }
|
||||
) !== null) {
|
||||
throw new Error("Expected a valid library-driven connection.");
|
||||
}
|
||||
|
||||
graph.edges.push({ id: "edge", source: "start", target: "end" });
|
||||
if (!definitionConnectionError(
|
||||
graph,
|
||||
library,
|
||||
{ source: "start", target: "end" }
|
||||
)) {
|
||||
throw new Error("Expected duplicate connection validation.");
|
||||
}
|
||||
|
||||
const created = createDefinitionGraphNode("start", { x: 40, y: 50 }, library);
|
||||
if (created.config.mode !== "manual" || created.position.x !== 40) {
|
||||
throw new Error("Expected node creation to clone library defaults.");
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
routeContributionsForModules,
|
||||
uiCapability
|
||||
} from "../src/platform/moduleLogic";
|
||||
import {
|
||||
isViewSurfaceVisible,
|
||||
viewSurfaceCatalogueForModules,
|
||||
visibleRoutesForProjection
|
||||
} from "../src/platform/views";
|
||||
import { scopeGrants } from "../src/utils/permissions";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
@@ -96,3 +101,94 @@ functionAction.onClick({
|
||||
});
|
||||
assert(selectedFunctionId === "function-1", "organization function actions receive their row context");
|
||||
assert(!("render" in functionAction), "organization function actions expose metadata instead of arbitrary rendered controls");
|
||||
|
||||
const viewAwareFiles: PlatformWebModule = {
|
||||
...files,
|
||||
navItems: [{ to: "/files", label: "Files", order: 20 }],
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "files.settings.connectors",
|
||||
moduleId: "files",
|
||||
kind: "section",
|
||||
label: "File connectors"
|
||||
}
|
||||
]
|
||||
};
|
||||
const viewCatalogue = viewSurfaceCatalogueForModules([viewAwareFiles]);
|
||||
assert(
|
||||
viewCatalogue.some((surface) => surface.id === "files.module"),
|
||||
"view catalogue should derive a module root"
|
||||
);
|
||||
assert(
|
||||
viewCatalogue.some((surface) => surface.id === "files.nav.files"),
|
||||
"view catalogue should derive navigation surfaces"
|
||||
);
|
||||
assert(
|
||||
viewCatalogue.some((surface) => surface.id === "files.route.files"),
|
||||
"view catalogue should derive route surfaces"
|
||||
);
|
||||
|
||||
const serverNormalizedCatalogue = viewSurfaceCatalogueForModules([
|
||||
{
|
||||
...viewAwareFiles,
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "files.module",
|
||||
moduleId: "files",
|
||||
kind: "module",
|
||||
label: "Files",
|
||||
parentId: null
|
||||
},
|
||||
...(viewAwareFiles.viewSurfaces ?? [])
|
||||
]
|
||||
}
|
||||
]);
|
||||
assert(
|
||||
serverNormalizedCatalogue.find((surface) => surface.id === "files.module")
|
||||
?.parentId == null,
|
||||
"server-normalized module roots must not become their own parent"
|
||||
);
|
||||
|
||||
const filesView = {
|
||||
activeViewId: "view-files",
|
||||
activeRevisionId: "revision-files",
|
||||
activeViewName: "Files only",
|
||||
visibleSurfaceIds: [
|
||||
"files.module",
|
||||
"files.nav.files",
|
||||
"files.route.files"
|
||||
],
|
||||
locked: false,
|
||||
availableViews: [],
|
||||
provenance: [],
|
||||
diagnostics: []
|
||||
};
|
||||
assert(
|
||||
isViewSurfaceVisible(filesView, "files.route.files", viewCatalogue),
|
||||
"selected child surfaces should remain visible when their ancestor is selected"
|
||||
);
|
||||
assert(
|
||||
!isViewSurfaceVisible(filesView, "files.settings.connectors", viewCatalogue),
|
||||
"unselected sections should be hidden"
|
||||
);
|
||||
assert(
|
||||
visibleRoutesForProjection([viewAwareFiles], filesView).length === 1,
|
||||
"selected routes should remain in the effective route list"
|
||||
);
|
||||
|
||||
const missingParentView = {
|
||||
...filesView,
|
||||
visibleSurfaceIds: ["files.route.files"]
|
||||
};
|
||||
assert(
|
||||
!isViewSurfaceVisible(
|
||||
missingParentView,
|
||||
"files.route.files",
|
||||
viewCatalogue
|
||||
),
|
||||
"a selected child should stay hidden when its module ancestor is not selected"
|
||||
);
|
||||
assert(
|
||||
isViewSurfaceVisible(filesView, "future.module.action", viewCatalogue),
|
||||
"unknown surfaces should fail open for forward compatibility and recovery"
|
||||
);
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"tests/module-capabilities.test.ts",
|
||||
"tests/privacy-policy.test.ts",
|
||||
"tests/help-context.test.ts",
|
||||
"tests/definition-graph.test.ts",
|
||||
"src/platform/moduleLogic.ts",
|
||||
"src/utils/helpContext.ts",
|
||||
"src/features/privacy/policyLogic.ts",
|
||||
"src/definitionGraph.ts",
|
||||
"src/types.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ const defaultWebModulePackages = [
|
||||
"@govoplan/audit-webui",
|
||||
"@govoplan/calendar-webui",
|
||||
"@govoplan/campaign-webui",
|
||||
"@govoplan/dataflow-webui",
|
||||
"@govoplan/datasources-webui",
|
||||
"@govoplan/dashboard-webui",
|
||||
"@govoplan/docs-webui",
|
||||
"@govoplan/files-webui",
|
||||
@@ -26,7 +28,9 @@ const defaultWebModulePackages = [
|
||||
"@govoplan/organizations-webui",
|
||||
"@govoplan/ops-webui",
|
||||
"@govoplan/policy-webui",
|
||||
"@govoplan/scheduling-webui"
|
||||
"@govoplan/scheduling-webui",
|
||||
"@govoplan/views-webui",
|
||||
"@govoplan/workflow-webui"
|
||||
];
|
||||
|
||||
function configuredWebModulePackages(): string[] {
|
||||
@@ -76,7 +80,10 @@ function govoplanInstalledModulesPlugin(): Plugin {
|
||||
export default defineConfig({
|
||||
plugins: [govoplanInstalledModulesPlugin(), react()],
|
||||
optimizeDeps: {
|
||||
exclude: availableWebModuleSpecifiers()
|
||||
exclude: availableWebModuleSpecifiers(),
|
||||
// Dataflow is discovered through the virtual module registry, so Vite's
|
||||
// initial scan cannot see this CommonJS-backed transitive dependency.
|
||||
include: ["@xyflow/react"]
|
||||
},
|
||||
build: {
|
||||
// Full-product builds include the host shell plus all installed module wiring.
|
||||
@@ -85,9 +92,15 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
dedupe: ["react", "react-dom", "react-router-dom"],
|
||||
dedupe: [
|
||||
"@xyflow/react",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-router-dom"
|
||||
],
|
||||
alias: [
|
||||
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
|
||||
{ find: "@govoplan/core-webui/definition-graph", replacement: fileURLToPath(new URL("./src/definitionGraph.ts", import.meta.url)) },
|
||||
{ find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) }
|
||||
]
|
||||
},
|
||||
@@ -100,6 +113,8 @@ export default defineConfig({
|
||||
fileURLToPath(new URL('../../govoplan-addresses/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-audit/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-calendar/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-dataflow/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-datasources/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)),
|
||||
@@ -110,7 +125,9 @@ export default defineConfig({
|
||||
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url))
|
||||
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
|
||||
]
|
||||
},
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user