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).
|
**Repository type:** system (kernel).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- 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.
|
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
|
## Repository ownership
|
||||||
@@ -54,7 +47,7 @@ python3 -m venv .venv
|
|||||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
./.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
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
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
|
raise
|
||||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
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.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.core.migrations import migration_metadata_plan
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.server.default_config import get_server_config
|
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.
|
planning context and should be mirrored to the Gitea wiki.
|
||||||
|
|
||||||
The meta repository's
|
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
|
describes the corresponding cross-product stakeholder visions, configurable
|
||||||
service and operating configurations, connected outcome stories, and
|
service and operating configurations, connected outcome stories, and
|
||||||
capability horizons. The selected five-stage delivery sequence and its gates
|
capability horizons. The selected five-stage delivery sequence and its gates
|
||||||
are in the meta repository's
|
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
|
Those product documents are canonical; this Core roadmap remains their
|
||||||
technical sequencing and module-routing companion.
|
technical sequencing and module-routing companion.
|
||||||
|
|
||||||
@@ -625,48 +625,48 @@ repositories or to explicit missing-module decisions.
|
|||||||
|
|
||||||
| Idea | Owner | Tracking |
|
| Idea | Owner | Tracking |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Government operations backbone reference model | `govoplan-core` | `add-ideas/govoplan-core#213` |
|
| Government operations backbone reference model | `govoplan-core` | `GovOPlaN/govoplan-core#213` |
|
||||||
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `add-ideas/govoplan-core#214` |
|
| 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` | `add-ideas/govoplan-core#218` |
|
| 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` | `add-ideas/govoplan-access#7` |
|
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
|
||||||
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `add-ideas/govoplan-core#227` |
|
| 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` | `add-ideas/govoplan-workflow#1` |
|
| 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` | `add-ideas/govoplan-postbox#15`, `add-ideas/govoplan-identity-trust#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` | `add-ideas/govoplan-access#9` |
|
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
|
||||||
| Role-based service directory/catalog | `govoplan-portal` | `add-ideas/govoplan-portal#1` |
|
| 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 | `add-ideas/govoplan-tasks#1`, `add-ideas/govoplan-notifications#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` | `add-ideas/govoplan-connectors#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 | `add-ideas/govoplan-core#196`, `add-ideas/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 | `add-ideas/govoplan-core#197` |
|
| 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 | `add-ideas/govoplan-core#198` |
|
| 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 | `add-ideas/govoplan-core#216` |
|
| 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 | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-templates#1` |
|
| 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 | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-reporting#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` | `add-ideas/govoplan-files#15` |
|
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/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 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 | `add-ideas/govoplan-core#215` |
|
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
|
||||||
| Cases module concept | `govoplan-cases` | `add-ideas/govoplan-core#174` |
|
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
|
||||||
| Workflow module concept | `govoplan-workflow` | `add-ideas/govoplan-core#175` |
|
| Workflow module concept | `govoplan-workflow` | `GovOPlaN/govoplan-core#175` |
|
||||||
| Connectors module concept | `govoplan-connectors` | `add-ideas/govoplan-core#176` |
|
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
|
||||||
| Adrema-style address and distribution-list management | `govoplan-addresses` | `add-ideas/govoplan-addresses#1` |
|
| 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` | `add-ideas/govoplan-connectors#3`, `add-ideas/govoplan-core#198` |
|
| 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` | `add-ideas/govoplan-connectors#6` |
|
| 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 | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-scheduling#1` |
|
| 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` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-calendar#1` |
|
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
|
||||||
| Terminbuchung appointment booking | `govoplan-appointments` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-appointments#1` |
|
| Terminbuchung appointment booking | `govoplan-appointments` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-appointments#1` |
|
||||||
| Collaborative documents | `govoplan-dms` | `add-ideas/govoplan-dms#1` |
|
| Collaborative documents | `govoplan-dms` | `GovOPlaN/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` |
|
| 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` | `add-ideas/govoplan-connectors#4` |
|
| RSS consume and emit | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#4` |
|
||||||
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `add-ideas/govoplan-idm#1` |
|
| 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 | `add-ideas/govoplan-core#195`, `add-ideas/govoplan-connectors#5` |
|
| 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` | `add-ideas/govoplan-mail#5` |
|
| Open-Xchange mail/groupware | `govoplan-mail` | `GovOPlaN/govoplan-mail#5` |
|
||||||
| Open-Xchange calendar | `govoplan-calendar` | `add-ideas/govoplan-calendar#2` |
|
| Open-Xchange calendar | `govoplan-calendar` | `GovOPlaN/govoplan-calendar#2` |
|
||||||
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#217` |
|
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#217` |
|
||||||
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#219` |
|
| 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` | `add-ideas/govoplan-core#220` |
|
| 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` | `add-ideas/govoplan-core#19` |
|
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#19` |
|
||||||
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#26` |
|
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#26` |
|
||||||
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#28` |
|
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#28` |
|
||||||
|
|
||||||
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
|
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
|
## Next Practical Work
|
||||||
|
|
||||||
The active cross-product story is
|
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.
|
Module repositories own implementation issues; do not clone their state here.
|
||||||
|
|
||||||
Immediate issue buckets:
|
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. |
|
| 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. |
|
| 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. |
|
| 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
|
## 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
|
responses remain module-local because the same counters are exposed from
|
||||||
different menu contexts and are not yet a separately versioned platform API.
|
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
|
## Frontend Contract
|
||||||
|
|
||||||
Policy UIs must:
|
Policy UIs must:
|
||||||
|
|||||||
@@ -40,26 +40,26 @@ cd /mnt/DATA/git/govoplan
|
|||||||
Update those refs when cutting a release:
|
Update those refs when cutting a release:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.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:add-ideas/govoplan-organizations.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:add-ideas/govoplan-identity.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:add-ideas/govoplan-idm.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:add-ideas/govoplan-access.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:add-ideas/govoplan-admin.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:add-ideas/govoplan-policy.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:add-ideas/govoplan-audit.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:add-ideas/govoplan-dashboard.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:add-ideas/govoplan-addresses.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:add-ideas/govoplan-files.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:add-ideas/govoplan-mail.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:add-ideas/govoplan-campaign.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:add-ideas/govoplan-calendar.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:add-ideas/govoplan-poll.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:add-ideas/govoplan-scheduling.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:add-ideas/govoplan-notifications.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:add-ideas/govoplan-evaluation.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:add-ideas/govoplan-docs.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:add-ideas/govoplan-ops.git v0.1.8
|
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
|
||||||
```
|
```
|
||||||
|
|
||||||
## WebUI Packages
|
## 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
|
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 baseline and add a new release-track step-up instead of replacing prior
|
||||||
release shortcuts. The tracking issue is
|
release shortcuts. The tracking issue is
|
||||||
`add-ideas/govoplan-core#223`.
|
`GovOPlaN/govoplan-core#223`.
|
||||||
|
|
||||||
## Related Operator Documents
|
## 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
|
unless the decision is explicitly revised here and affected screens are updated
|
||||||
to match.
|
to match.
|
||||||
|
|
||||||
Active tracking issue: `add-ideas/govoplan-core#225`.
|
Active tracking issue: `GovOPlaN/govoplan-core#225`.
|
||||||
|
|
||||||
## Operating Rule
|
## 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.",
|
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
||||||
"publisher": "ADD ideas",
|
"publisher": "ADD ideas",
|
||||||
"category": "workflow",
|
"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>",
|
"artifact_sha256": "<sha256>",
|
||||||
"required_modules": [
|
"required_modules": [
|
||||||
{ "module_id": "portal", "version": ">=0.1.0" },
|
{ "module_id": "portal", "version": ">=0.1.0" },
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-files",
|
"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_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_safety": "forward_only",
|
||||||
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||||
"migration_after": ["access"],
|
"migration_after": ["access"],
|
||||||
@@ -54,14 +54,14 @@
|
|||||||
],
|
],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"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>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
|
"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",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.4"
|
"git_ref": "refs/tags/v0.1.4"
|
||||||
},
|
},
|
||||||
"webui": {
|
"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>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
|
"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",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
|
||||||
@@ -78,9 +78,9 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-mail",
|
"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_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": [
|
"provides_interfaces": [
|
||||||
{
|
{
|
||||||
"name": "mail.campaign_delivery",
|
"name": "mail.campaign_delivery",
|
||||||
@@ -97,14 +97,14 @@
|
|||||||
],
|
],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"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>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
|
"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",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.4"
|
"git_ref": "refs/tags/v0.1.4"
|
||||||
},
|
},
|
||||||
"webui": {
|
"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>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
|
"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",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
|
||||||
@@ -121,19 +121,19 @@
|
|||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-dashboard",
|
"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_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": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"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>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
|
"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",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.6"
|
"git_ref": "refs/tags/v0.1.6"
|
||||||
},
|
},
|
||||||
"webui": {
|
"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>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
|
"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",
|
"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.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
||||||
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
|
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.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
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": {"queue": "notifications"},
|
||||||
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
||||||
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
|
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
|
||||||
|
"govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"},
|
||||||
},
|
},
|
||||||
worker_prefetch_multiplier=1,
|
worker_prefetch_multiplier=1,
|
||||||
task_acks_late=True,
|
task_acks_late=True,
|
||||||
@@ -39,6 +44,11 @@ celery.conf.update(
|
|||||||
"schedule": 60.0,
|
"schedule": 60.0,
|
||||||
"args": (None, 100),
|
"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
|
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)
|
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
|
||||||
def send_email(self, job_id: str):
|
def send_email(self, job_id: str):
|
||||||
"""Send one explicitly queued campaign job.
|
"""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))
|
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
|
||||||
session.commit()
|
session.commit()
|
||||||
return result
|
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_AUDIT_RETENTION = "audit.retention"
|
||||||
|
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER = (
|
||||||
|
"auth.automationPrincipalProvider"
|
||||||
|
)
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
||||||
@@ -48,6 +51,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
|||||||
CAPABILITY_AUDIT_RECORDER,
|
CAPABILITY_AUDIT_RECORDER,
|
||||||
CAPABILITY_AUDIT_RETENTION,
|
CAPABILITY_AUDIT_RETENTION,
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||||
@@ -57,6 +61,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
|||||||
AUTH_CAPABILITY_NAMES = frozenset(
|
AUTH_CAPABILITY_NAMES = frozenset(
|
||||||
{
|
{
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
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 dataclasses import dataclass, field
|
||||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||||
|
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ class NavItem:
|
|||||||
required_all: tuple[str, ...] = ()
|
required_all: tuple[str, ...] = ()
|
||||||
required_any: tuple[str, ...] = ()
|
required_any: tuple[str, ...] = ()
|
||||||
order: int = 100
|
order: int = 100
|
||||||
|
surface_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ class FrontendRoute:
|
|||||||
required_all: tuple[str, ...] = ()
|
required_all: tuple[str, ...] = ()
|
||||||
required_any: tuple[str, ...] = ()
|
required_any: tuple[str, ...] = ()
|
||||||
order: int = 100
|
order: int = 100
|
||||||
|
surface_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -92,6 +96,7 @@ class FrontendModule:
|
|||||||
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||||
nav_items: tuple[NavItem, ...] = ()
|
nav_items: tuple[NavItem, ...] = ()
|
||||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||||
|
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@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 typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
|
||||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||||
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
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_PRIVACY_RETENTION = "policy.privacyRetention"
|
||||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
|
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
|
||||||
|
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
|
||||||
|
|
||||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SchedulingParticipantPrivacyRequest:
|
class SchedulingParticipantPrivacyRequest:
|
||||||
"""Context for resolving what one Scheduling participant may see.
|
"""Context for resolving what one Scheduling participant may see.
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ from govoplan_core.core.modules import (
|
|||||||
user_workflow_scope_condition_issues,
|
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.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_]*$")
|
_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_.-]*$")
|
_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, ...]:
|
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))
|
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, ...]:
|
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
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)
|
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(
|
def _normalize_delete_veto_result(
|
||||||
result: object,
|
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}")
|
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):
|
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
_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:
|
for item in frontend.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_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:
|
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.
|
# 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.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.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)
|
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
||||||
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_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.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.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.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
|
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.config import GovoplanServerConfig, load_server_config
|
||||||
from govoplan_core.server.fastapi import create_govoplan_app
|
from govoplan_core.server.fastapi import create_govoplan_app
|
||||||
from govoplan_core.server.platform import create_platform_router
|
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.registry import available_module_manifests, build_platform_registry
|
||||||
from govoplan_core.server.route_validation import validate_no_route_collisions
|
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:
|
for router in server_config.base_routers:
|
||||||
api_router.include_router(router)
|
api_router.include_router(router)
|
||||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
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:
|
for router in server_config.post_module_routers:
|
||||||
api_router.include_router(router)
|
api_router.include_router(router)
|
||||||
for contribution in server_config.extra_routers:
|
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.models import SystemSettings
|
||||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
|
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
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.db.session import get_database
|
||||||
from govoplan_core.i18n import system_i18n_payload
|
from govoplan_core.i18n import system_i18n_payload
|
||||||
|
|
||||||
@@ -19,7 +25,7 @@ def _registry(request: Request) -> PlatformRegistry:
|
|||||||
return registry
|
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 {
|
return {
|
||||||
"path": item.path,
|
"path": item.path,
|
||||||
"label": item.label,
|
"label": item.label,
|
||||||
@@ -28,16 +34,26 @@ def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
|||||||
"required_all": list(item.required_all),
|
"required_all": list(item.required_all),
|
||||||
"required_any": list(item.required_any),
|
"required_any": list(item.required_any),
|
||||||
"order": item.order,
|
"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 {
|
return {
|
||||||
"path": route.path,
|
"path": route.path,
|
||||||
"component": route.component,
|
"component": route.component,
|
||||||
"required_all": list(route.required_all),
|
"required_all": list(route.required_all),
|
||||||
"required_any": list(route.required_any),
|
"required_any": list(route.required_any),
|
||||||
"order": route.order,
|
"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:
|
if frontend is None:
|
||||||
return None
|
return None
|
||||||
return {
|
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_public_key_id": frontend.asset_manifest_public_key_id,
|
||||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
"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_routes": [
|
||||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||||
],
|
],
|
||||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
"nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items],
|
||||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
"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),
|
"optional_dependencies": list(manifest.optional_dependencies),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||||
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
|
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||||
"frontend": _frontend_payload(manifest.frontend),
|
"frontend": _frontend_payload(manifest),
|
||||||
}
|
}
|
||||||
for manifest in registry.manifests()
|
for manifest in registry.manifests()
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
|
|||||||
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
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_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")
|
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")
|
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
||||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
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(current, configured_migration_heads(url, migration_track="dev"))
|
||||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
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("audit_outbox_events", tables)
|
||||||
self.assertIn("calendar_outbox_operations", tables)
|
self.assertIn("calendar_outbox_operations", tables)
|
||||||
self.assertIn("file_connector_profiles", 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",
|
"module_id": "files",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-files",
|
"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_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"))
|
save_desired_enabled_modules(session, ("tenancy", "access", "files"))
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -931,8 +931,8 @@ finally:
|
|||||||
self.assertEqual(("files",), tuple(item.module_id for item in saved.items))
|
self.assertEqual(("files",), tuple(item.module_id for item in saved.items))
|
||||||
self.assertEqual("manual", saved.items[0].source)
|
self.assertEqual("manual", saved.items[0].source)
|
||||||
self.assertEqual((
|
self.assertEqual((
|
||||||
"python -m pip install 'govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4'",
|
"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/add-ideas/govoplan-files.git#v0.1.4' && npm install",
|
"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))
|
), module_install_plan_commands(saved))
|
||||||
|
|
||||||
def test_module_install_plan_preserves_catalog_source(self) -> None:
|
def test_module_install_plan_preserves_catalog_source(self) -> None:
|
||||||
@@ -961,7 +961,7 @@ finally:
|
|||||||
"python_package": "govoplan-files",
|
"python_package": "govoplan-files",
|
||||||
"python_ref": "govoplan-files==0.2.0",
|
"python_ref": "govoplan-files==0.2.0",
|
||||||
"webui_package": "@govoplan/files-webui",
|
"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,
|
"data_safety_acknowledged": True,
|
||||||
})
|
})
|
||||||
plan = ModuleInstallPlan(items=(item,))
|
plan = ModuleInstallPlan(items=(item,))
|
||||||
@@ -971,7 +971,7 @@ finally:
|
|||||||
self.assertTrue(item.as_dict()["data_safety_acknowledged"])
|
self.assertTrue(item.as_dict()["data_safety_acknowledged"])
|
||||||
self.assertEqual((
|
self.assertEqual((
|
||||||
"python -m pip install govoplan-files==0.2.0",
|
"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))
|
), module_install_plan_commands(plan))
|
||||||
|
|
||||||
def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None:
|
def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None:
|
||||||
@@ -996,7 +996,7 @@ finally:
|
|||||||
"python_package": "govoplan-files",
|
"python_package": "govoplan-files",
|
||||||
"python_ref": "govoplan-files==0.1.6",
|
"python_ref": "govoplan-files==0.1.6",
|
||||||
"webui_package": "@govoplan/files-webui",
|
"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")
|
}), 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/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||||
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/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/docs-webui": "file:../../govoplan-docs/webui",
|
||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||||
@@ -23,12 +25,15 @@
|
|||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
"@govoplan/policy-webui": "file:../../govoplan-policy/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": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^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": {
|
"../../govoplan-docs/webui": {
|
||||||
"name": "@govoplan/docs-webui",
|
"name": "@govoplan/docs-webui",
|
||||||
"version": "0.1.10",
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -1091,6 +1165,14 @@
|
|||||||
"resolved": "../../govoplan-dashboard/webui",
|
"resolved": "../../govoplan-dashboard/webui",
|
||||||
"link": true
|
"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": {
|
"node_modules/@govoplan/docs-webui": {
|
||||||
"resolved": "../../govoplan-docs/webui",
|
"resolved": "../../govoplan-docs/webui",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -1127,6 +1209,14 @@
|
|||||||
"resolved": "../../govoplan-scheduling/webui",
|
"resolved": "../../govoplan-scheduling/webui",
|
||||||
"link": true
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -1618,6 +1708,61 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1676,6 +1821,50 @@
|
|||||||
"node": ">=14.6"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.38",
|
"version": "2.10.38",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
||||||
@@ -1744,6 +1933,13 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1772,6 +1968,120 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1967,9 +2277,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.15",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2023,9 +2333,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2043,7 +2353,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.16",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -2294,6 +2604,16 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.3",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
@@ -2375,6 +2695,35 @@
|
|||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.14",
|
"version": "0.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.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/add-ideas/govoplan-admin.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/add-ideas/govoplan-audit.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/add-ideas/govoplan-calendar.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/add-ideas/govoplan-campaign.git#v0.1.11",
|
"@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/add-ideas/govoplan-dashboard.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/add-ideas/govoplan-docs.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/add-ideas/govoplan-files.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/add-ideas/govoplan-idm.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/add-ideas/govoplan-mail.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/add-ideas/govoplan-ops.git#v0.1.8",
|
"@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/add-ideas/govoplan-organizations.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/add-ideas/govoplan-policy.git#v0.1.8"
|
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
@@ -721,7 +721,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/access-webui": {
|
"node_modules/@govoplan/access-webui": {
|
||||||
"version": "0.1.8",
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
@@ -737,7 +737,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/admin-webui": {
|
"node_modules/@govoplan/admin-webui": {
|
||||||
"version": "0.1.8",
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
@@ -753,7 +753,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/audit-webui": {
|
"node_modules/@govoplan/audit-webui": {
|
||||||
"version": "0.1.8",
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
@@ -769,7 +769,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/calendar-webui": {
|
"node_modules/@govoplan/calendar-webui": {
|
||||||
"version": "0.1.8",
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -788,7 +788,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/campaign-webui": {
|
"node_modules/@govoplan/campaign-webui": {
|
||||||
"version": "0.1.11",
|
"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": {
|
"dependencies": {
|
||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
@@ -807,7 +807,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/dashboard-webui": {
|
"node_modules/@govoplan/dashboard-webui": {
|
||||||
"version": "0.1.8",
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
@@ -823,7 +823,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/docs-webui": {
|
"node_modules/@govoplan/docs-webui": {
|
||||||
"version": "0.1.8",
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -842,7 +842,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/files-webui": {
|
"node_modules/@govoplan/files-webui": {
|
||||||
"version": "0.1.8",
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -861,7 +861,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/idm-webui": {
|
"node_modules/@govoplan/idm-webui": {
|
||||||
"version": "0.1.8",
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -879,10 +879,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/mail-webui": {
|
"node_modules/@govoplan/mail-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.10",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#5371cb3ad4b3882a3ffc377643183f66ab318619",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#3e23029090224fd01741242008e4991e0e548de1",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -896,7 +896,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/ops-webui": {
|
"node_modules/@govoplan/ops-webui": {
|
||||||
"version": "0.1.8",
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -915,7 +915,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/organizations-webui": {
|
"node_modules/@govoplan/organizations-webui": {
|
||||||
"version": "0.1.8",
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -934,7 +934,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/policy-webui": {
|
"node_modules/@govoplan/policy-webui": {
|
||||||
"version": "0.1.8",
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
"./app": {
|
"./app": {
|
||||||
"types": "./src/app.ts",
|
"types": "./src/app.ts",
|
||||||
"import": "./src/app.ts"
|
"import": "./src/app.ts"
|
||||||
|
},
|
||||||
|
"./definition-graph": {
|
||||||
|
"types": "./src/definitionGraph.ts",
|
||||||
|
"import": "./src/definitionGraph.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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: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: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: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: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: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",
|
"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/audit-webui": "file:../../govoplan-audit/webui",
|
||||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/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/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
||||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||||
@@ -50,12 +56,15 @@
|
|||||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||||
"@govoplan/policy-webui": "file:../../govoplan-policy/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": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -22,19 +22,19 @@
|
|||||||
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.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/add-ideas/govoplan-admin.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/add-ideas/govoplan-audit.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/add-ideas/govoplan-calendar.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/add-ideas/govoplan-dashboard.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/add-ideas/govoplan-docs.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/add-ideas/govoplan-files.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/add-ideas/govoplan-idm.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/add-ideas/govoplan-mail.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/add-ideas/govoplan-campaign.git#v0.1.11",
|
"@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/add-ideas/govoplan-organizations.git#v0.1.8",
|
"@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/add-ideas/govoplan-ops.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/add-ideas/govoplan-policy.git#v0.1.8"
|
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const packageByModule = {
|
|||||||
calendar: "@govoplan/calendar-webui",
|
calendar: "@govoplan/calendar-webui",
|
||||||
campaigns: "@govoplan/campaign-webui",
|
campaigns: "@govoplan/campaign-webui",
|
||||||
dashboard: "@govoplan/dashboard-webui",
|
dashboard: "@govoplan/dashboard-webui",
|
||||||
|
dataflow: "@govoplan/dataflow-webui",
|
||||||
|
datasources: "@govoplan/datasources-webui",
|
||||||
docs: "@govoplan/docs-webui",
|
docs: "@govoplan/docs-webui",
|
||||||
files: "@govoplan/files-webui",
|
files: "@govoplan/files-webui",
|
||||||
idm: "@govoplan/idm-webui",
|
idm: "@govoplan/idm-webui",
|
||||||
@@ -16,7 +18,9 @@ const packageByModule = {
|
|||||||
organizations: "@govoplan/organizations-webui",
|
organizations: "@govoplan/organizations-webui",
|
||||||
ops: "@govoplan/ops-webui",
|
ops: "@govoplan/ops-webui",
|
||||||
policy: "@govoplan/policy-webui",
|
policy: "@govoplan/policy-webui",
|
||||||
scheduling: "@govoplan/scheduling-webui"
|
scheduling: "@govoplan/scheduling-webui",
|
||||||
|
views: "@govoplan/views-webui",
|
||||||
|
workflow: "@govoplan/workflow-webui"
|
||||||
};
|
};
|
||||||
|
|
||||||
const cases = [
|
const cases = [
|
||||||
@@ -27,6 +31,13 @@ const cases = [
|
|||||||
{ name: "access-with-admin", modules: ["access", "admin"] },
|
{ name: "access-with-admin", modules: ["access", "admin"] },
|
||||||
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
|
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
|
||||||
{ name: "dashboard-only", modules: ["dashboard"] },
|
{ 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: "calendar-only", modules: ["calendar"] },
|
||||||
{ name: "files-only", modules: ["files"] },
|
{ name: "files-only", modules: ["files"] },
|
||||||
{ name: "mail-only", modules: ["mail"] },
|
{ name: "mail-only", modules: ["mail"] },
|
||||||
@@ -39,7 +50,7 @@ const cases = [
|
|||||||
{ name: "scheduling-only", modules: ["scheduling"] },
|
{ name: "scheduling-only", modules: ["scheduling"] },
|
||||||
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
||||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
{ 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;
|
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 { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||||
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
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 AppShell from "./layout/AppShell";
|
||||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||||
import LoginModal from "./features/auth/LoginModal";
|
import LoginModal from "./features/auth/LoginModal";
|
||||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
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 { 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 { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||||
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
||||||
|
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
|
||||||
|
|
||||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||||
@@ -37,18 +40,59 @@ export default function App() {
|
|||||||
const [backendReachable, setBackendReachable] = useState(true);
|
const [backendReachable, setBackendReachable] = useState(true);
|
||||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||||
const [reloginMessage, setReloginMessage] = useState("");
|
const [reloginMessage, setReloginMessage] = useState("");
|
||||||
|
const [viewProjection, setViewProjection] = useState<EffectiveViewProjection | null>(null);
|
||||||
|
|
||||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||||
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
||||||
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
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 moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||||
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
||||||
const contextModules = auth ? webModules : publicWebModules;
|
const contextModules = auth ? webModules : publicWebModules;
|
||||||
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
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) {
|
function updateSettings(next: ApiSettings) {
|
||||||
setSettings(next);
|
setSettings(next);
|
||||||
saveApiSettings(next);
|
saveApiSettings(next);
|
||||||
@@ -311,6 +355,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={publicWebModules}>
|
<PlatformModulesProvider modules={publicWebModules}>
|
||||||
|
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<div className="public-landing">
|
<div className="public-landing">
|
||||||
@@ -322,6 +367,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
|
</PlatformViewProvider>
|
||||||
</PlatformModulesProvider>
|
</PlatformModulesProvider>
|
||||||
</PlatformLanguageProvider>);
|
</PlatformLanguageProvider>);
|
||||||
|
|
||||||
@@ -331,6 +377,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={publicWebModules}>
|
<PlatformModulesProvider modules={publicWebModules}>
|
||||||
|
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<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>}>
|
<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>
|
</Suspense>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
|
</PlatformViewProvider>
|
||||||
</PlatformModulesProvider>
|
</PlatformModulesProvider>
|
||||||
</PlatformLanguageProvider>);
|
</PlatformLanguageProvider>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultRoute = firstAccessibleRoute(auth, webModules);
|
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
|
||||||
const authAvailableLanguages = auth.available_languages?.map((item) => ({
|
const authAvailableLanguages = auth.available_languages?.map((item) => ({
|
||||||
code: item.code,
|
code: item.code,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
@@ -373,6 +421,7 @@ export default function App() {
|
|||||||
onLanguageChange={persistLanguagePreference}
|
onLanguageChange={persistLanguagePreference}
|
||||||
moduleTranslations={moduleTranslations}>
|
moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={webModules}>
|
||||||
|
<PlatformViewProvider modules={webModules} projection={viewProjection}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<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>}>
|
<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}
|
path={route.path}
|
||||||
element={
|
element={
|
||||||
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
|
<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 })}
|
{route.render({ settings, auth, onAuthChange: updateAuth })}
|
||||||
|
</ViewSurfaceRouteBoundary>
|
||||||
</PermissionBoundary>
|
</PermissionBoundary>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
@@ -412,6 +467,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
|
</PlatformViewProvider>
|
||||||
</PlatformModulesProvider>
|
</PlatformModulesProvider>
|
||||||
</PlatformLanguageProvider>);
|
</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 {
|
import type {
|
||||||
|
MailCredentialEnvelope,
|
||||||
MailImapTransportSettings,
|
MailImapTransportSettings,
|
||||||
MailProfilePatternKey,
|
MailProfilePatternKey,
|
||||||
MailProfilePolicy,
|
MailProfilePolicy,
|
||||||
@@ -11,11 +12,13 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
MailCredentialEnvelope,
|
||||||
MailCredentialPolicy,
|
MailCredentialPolicy,
|
||||||
MailProfilePatternKey,
|
MailProfilePatternKey,
|
||||||
MailProfilePolicy,
|
MailProfilePolicy,
|
||||||
MailProfileScope,
|
MailProfileScope,
|
||||||
MailSecurity,
|
MailSecurity,
|
||||||
|
MailServerEndpoint,
|
||||||
MailServerProfile
|
MailServerProfile
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
@@ -24,6 +27,7 @@ export type MailImapTestPayload = MailImapTransportSettings;
|
|||||||
export type MailTransportCredentialsPayload = MailTransportCredentials;
|
export type MailTransportCredentialsPayload = MailTransportCredentials;
|
||||||
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
|
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
|
||||||
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
||||||
|
export type MailCredentialListResponse = { credentials: MailCredentialEnvelope[] };
|
||||||
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
||||||
|
|
||||||
export type MailConnectionTestResponse = {
|
export type MailConnectionTestResponse = {
|
||||||
@@ -110,6 +114,7 @@ export type MailServerProfilePayload = {
|
|||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
inherit_to_lower_scopes?: boolean;
|
||||||
scope_type?: MailProfileScope;
|
scope_type?: MailProfileScope;
|
||||||
scope_id?: string | null;
|
scope_id?: string | null;
|
||||||
smtp: MailSmtpTestPayload;
|
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 Card from "../components/Card";
|
||||||
|
import PageScrollViewport from "../components/PageScrollViewport";
|
||||||
|
|
||||||
export default function PlaceholderPage({ title }: {title: string;}) {
|
export default function PlaceholderPage({ title }: {title: string;}) {
|
||||||
return (
|
return (
|
||||||
|
<PageScrollViewport>
|
||||||
<div className="content-pad">
|
<div className="content-pad">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
@@ -10,6 +12,7 @@ export default function PlaceholderPage({ title }: {title: string;}) {
|
|||||||
<Card>
|
<Card>
|
||||||
<p className="muted">i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade</p>
|
<p className="muted">i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>);
|
</div>
|
||||||
|
</PageScrollViewport>);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import MetricCard from "../../components/MetricCard";
|
import MetricCard from "../../components/MetricCard";
|
||||||
|
import PageScrollViewport from "../../components/PageScrollViewport";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import { usePlatformModules } from "../../platform/ModuleContext";
|
import { usePlatformModules } from "../../platform/ModuleContext";
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ export default function DashboardPage() {
|
|||||||
const modules = usePlatformModules();
|
const modules = usePlatformModules();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<PageScrollViewport className="core-dashboard-page">
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<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>
|
<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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>
|
||||||
|
</PageScrollViewport>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import DismissibleAlert from "../../components/DismissibleAlert";
|
|||||||
import SegmentedControl from "../../components/SegmentedControl";
|
import SegmentedControl from "../../components/SegmentedControl";
|
||||||
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||||
|
import { useEffectiveView, useViewSurfaces } from "../../platform/ViewContext";
|
||||||
|
import { isViewSurfaceVisible } from "../../platform/views";
|
||||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||||
|
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
|
||||||
|
|
||||||
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
|
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" }
|
{ 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>[] = [
|
const groups: ModuleSubnavGroup<SettingsSection>[] = [
|
||||||
{
|
{
|
||||||
title: "i18n:govoplan-core.account.f967543b",
|
title: "i18n:govoplan-core.account.f967543b",
|
||||||
items: [
|
items: [
|
||||||
{ id: "profile", label: "i18n:govoplan-core.my_profile.2f3df0a9" },
|
{ id: "profile", label: "i18n:govoplan-core.my_profile.2f3df0a9" },
|
||||||
...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "i18n:govoplan-core.mail_profiles.8a8018b7" }] : []),
|
...(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 mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||||
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
|
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
|
||||||
|
const effectiveView = useEffectiveView();
|
||||||
|
const viewSurfaces = useViewSurfaces();
|
||||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? 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:read",
|
||||||
"mail_servers:write",
|
"mail_servers:write",
|
||||||
"mail_servers:manage_credentials",
|
"mail_servers:manage_credentials",
|
||||||
@@ -101,12 +107,26 @@ export default function SettingsPage({
|
|||||||
"admin:policies:read",
|
"admin:policies:read",
|
||||||
"admin:policies:write"
|
"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(
|
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 availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
|
||||||
const requestedSection = searchParams.get("section");
|
const requestedSection = searchParams.get("section");
|
||||||
const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface";
|
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" &&
|
{active === "interface" &&
|
||||||
<div className="dashboard-grid settings-dashboard-grid">
|
<div className="dashboard-grid settings-dashboard-grid">
|
||||||
<Card title="i18n:govoplan-core.interface_preferences.b82b39a7">
|
<Card title="i18n:govoplan-core.interface_preferences.b82b39a7">
|
||||||
@@ -505,6 +540,7 @@ function sectionOrder(sectionId: string, contributedSections: SettingsSectionCon
|
|||||||
profile: 10,
|
profile: 10,
|
||||||
"mail-profiles": 20,
|
"mail-profiles": 20,
|
||||||
"file-connectors": 30,
|
"file-connectors": 30,
|
||||||
|
"credentials": 40,
|
||||||
interface: 10,
|
interface: 10,
|
||||||
workspace: 20,
|
workspace: 20,
|
||||||
"local-connection": 30
|
"local-connection": 30
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export * from "./api/resourceAccess";
|
|||||||
export * from "./platform/modules";
|
export * from "./platform/modules";
|
||||||
export * from "./platform/ModuleContext";
|
export * from "./platform/ModuleContext";
|
||||||
export * from "./platform/moduleEvents";
|
export * from "./platform/moduleEvents";
|
||||||
|
export * from "./platform/ViewContext";
|
||||||
|
export * from "./platform/views";
|
||||||
|
|
||||||
export * from "./utils/permissions";
|
export * from "./utils/permissions";
|
||||||
export * from "./utils/fieldHelp";
|
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 ColorPickerField } from "./components/ColorPickerField";
|
||||||
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
|
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
|
||||||
export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } 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 DateField, TimeField, DateTimeField } from "./components/DateTimeField";
|
||||||
export { default as Dialog } from "./components/Dialog";
|
export { default as Dialog } from "./components/Dialog";
|
||||||
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
|
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
|
||||||
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
|
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
|
||||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
||||||
|
export { default as ViewSurfaceRouteBoundary } from "./components/ViewSurfaceRouteBoundary";
|
||||||
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
||||||
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
||||||
export { default as FileDropZone } from "./components/FileDropZone";
|
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 { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
|
||||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||||
export { default as PageTitle } from "./components/PageTitle";
|
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 PasswordField } from "./components/PasswordField";
|
||||||
export { default as PeoplePicker } from "./components/people/PeoplePicker";
|
export { default as PeoplePicker } from "./components/people/PeoplePicker";
|
||||||
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
|
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState, useEffect } from "react";
|
import { useRef, useState, useEffect } from "react";
|
||||||
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-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 HelpMenu from "./HelpMenu";
|
||||||
import LanguageMenu from "./LanguageMenu";
|
import LanguageMenu from "./LanguageMenu";
|
||||||
import LoginModal from "../features/auth/LoginModal";
|
import LoginModal from "../features/auth/LoginModal";
|
||||||
@@ -10,7 +10,8 @@ import { apiFetch, isApiError } from "../api/client";
|
|||||||
import { logout, switchTenant } from "../api/auth";
|
import { logout, switchTenant } from "../api/auth";
|
||||||
import { hasAnyScope } from "../utils/permissions";
|
import { hasAnyScope } from "../utils/permissions";
|
||||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
import { usePlatformModules } from "../platform/ModuleContext";
|
import { usePlatformModules, usePlatformUiCapability } from "../platform/ModuleContext";
|
||||||
|
import { useEffectiveView } from "../platform/ViewContext";
|
||||||
|
|
||||||
type NotificationSummary = {
|
type NotificationSummary = {
|
||||||
unread: number;
|
unread: number;
|
||||||
@@ -39,6 +40,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
const tenantRef = useRef<HTMLDivElement>(null);
|
const tenantRef = useRef<HTMLDivElement>(null);
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const modules = usePlatformModules();
|
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 activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||||
@@ -218,6 +222,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
|
|
||||||
<div className="titlebar-spacer" />
|
<div className="titlebar-spacer" />
|
||||||
|
|
||||||
|
{auth && ViewSelector &&
|
||||||
|
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||||
|
}
|
||||||
<LanguageMenu />
|
<LanguageMenu />
|
||||||
<HelpMenu auth={auth} />
|
<HelpMenu auth={auth} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createContext, type ReactNode, useContext } from "react";
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
import type { PlatformWebModule } from "../types";
|
import type { PlatformWebModule } from "../types";
|
||||||
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
|
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
|
||||||
|
import { useEffectiveView, useViewSurfaces } from "./ViewContext";
|
||||||
|
import { isViewSurfaceVisible, moduleViewSurfaceId } from "./views";
|
||||||
|
|
||||||
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
|
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
|
||||||
|
|
||||||
@@ -17,9 +19,22 @@ export function usePlatformModuleInstalled(moduleId: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
|
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[] {
|
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 type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||||
|
import { routeViewSurfaceId } from "./views";
|
||||||
|
|
||||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
@@ -30,7 +31,12 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
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[] {
|
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 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 {
|
import {
|
||||||
hasUiCapability as hasUiCapabilityForModules,
|
hasUiCapability as hasUiCapabilityForModules,
|
||||||
moduleInstalled as moduleInstalledForModules,
|
moduleInstalled as moduleInstalledForModules,
|
||||||
@@ -11,6 +11,12 @@ import {
|
|||||||
uiCapability as uiCapabilityForModules } from
|
uiCapability as uiCapabilityForModules } from
|
||||||
"./moduleLogic";
|
"./moduleLogic";
|
||||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||||
|
import {
|
||||||
|
isViewSurfaceVisible,
|
||||||
|
navigationViewSurfaceId,
|
||||||
|
routeViewSurfaceId,
|
||||||
|
viewSurfaceCatalogueForModules
|
||||||
|
} from "./views";
|
||||||
|
|
||||||
export const fallbackDashboardNavItem: PlatformNavItem =
|
export const fallbackDashboardNavItem: PlatformNavItem =
|
||||||
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
|
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
|
||||||
@@ -49,6 +55,7 @@ const iconByName: Record<string, LucideIcon> = {
|
|||||||
campaign: Mails,
|
campaign: Mails,
|
||||||
"clipboard-pen-line": ClipboardPenLine,
|
"clipboard-pen-line": ClipboardPenLine,
|
||||||
dashboard: LayoutDashboard,
|
dashboard: LayoutDashboard,
|
||||||
|
"database-zap": DatabaseZap,
|
||||||
file: Folder,
|
file: Folder,
|
||||||
files: Folder,
|
files: Folder,
|
||||||
folder: Folder,
|
folder: Folder,
|
||||||
@@ -61,7 +68,9 @@ const iconByName: Record<string, LucideIcon> = {
|
|||||||
"radio-tower": RadioTower,
|
"radio-tower": RadioTower,
|
||||||
reports: ClipboardPenLine,
|
reports: ClipboardPenLine,
|
||||||
templates: LayoutTemplate,
|
templates: LayoutTemplate,
|
||||||
users: Users
|
users: Users,
|
||||||
|
waypoints: Waypoints,
|
||||||
|
workflow: WorkflowIcon
|
||||||
};
|
};
|
||||||
|
|
||||||
export function iconComponentForName(iconName: string | null | undefined): LucideIcon | undefined {
|
export function iconComponentForName(iconName: string | null | undefined): LucideIcon | undefined {
|
||||||
@@ -83,10 +92,65 @@ function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavIt
|
|||||||
iconName: item.icon ?? null,
|
iconName: item.icon ?? null,
|
||||||
allOf: item.required_all,
|
allOf: item.required_all,
|
||||||
anyOf: item.required_any,
|
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) {
|
function runtimeUiCapabilitiesForModule(module: PlatformWebModule, info: PlatformModuleInfo) {
|
||||||
const enabledNames = new Set(info.runtime_ui_capabilities ?? []);
|
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,
|
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
||||||
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
||||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||||
|
routes: routesWithServerMetadata(module, info),
|
||||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||||
|
viewSurfaces: mergeViewSurfaces(module, info),
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
...(module.uiCapabilities ?? {}),
|
...(module.uiCapabilities ?? {}),
|
||||||
...runtimeUiCapabilitiesForModule(module, info)
|
...runtimeUiCapabilitiesForModule(module, info)
|
||||||
@@ -350,26 +416,42 @@ export function publicRouteContributionsForModules(modules: PlatformWebModule[])
|
|||||||
return publicRouteContributionsForModuleList(modules);
|
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).
|
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
||||||
flatMap((capability) => capability.widgets ?? []).
|
flatMap((capability) => capability.widgets ?? []).
|
||||||
|
filter((widget) => isViewSurfaceVisible(projection, widget.surfaceId, catalogue)).
|
||||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
export function navItemsForModules(
|
||||||
return [...shellNavItemsForModules(modules), ...modules.flatMap((module) => module.navItems ?? [])].
|
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).
|
map(resolveNavItemIcon).
|
||||||
|
filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)).
|
||||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules): PlatformNavItem[] {
|
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): PlatformNavItem[] {
|
||||||
return navItemsForModules(modules).filter((item) => {
|
return navItemsForModules(modules, projection).filter((item) => {
|
||||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||||
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules): string {
|
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): string {
|
||||||
return visibleNavItems(auth, modules)[0]?.to ?? "/dashboard";
|
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.active { border-left: 3px solid var(--accent); font-weight: 700; }
|
||||||
.section-link.subtle { font-size: 13px; }
|
.section-link.subtle { font-size: 13px; }
|
||||||
.workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; }
|
.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 .app-content { overflow: auto; }
|
||||||
.ui-no-sticky-section-sidebars .workspace { height: auto; min-height: 100%; }
|
.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 {
|
.ui-no-sticky-section-sidebars .section-sidebar {
|
||||||
height: max-content;
|
height: max-content;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
@@ -125,7 +127,10 @@
|
|||||||
.step-intro p { margin-top: 6px; color: var(--muted); }
|
.step-intro p { margin-top: 6px; color: var(--muted); }
|
||||||
.button-row { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; }
|
.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 { 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-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 { 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); }
|
.table-row-link:hover { background: var(--panel-soft); }
|
||||||
|
|||||||
@@ -227,6 +227,26 @@ export type CampaignListItem = {
|
|||||||
|
|
||||||
export type PlatformIconName = string;
|
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 = {
|
export type PlatformNavItem = {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -237,6 +257,7 @@ export type PlatformNavItem = {
|
|||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
order?: number;
|
order?: number;
|
||||||
|
surfaceId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlatformRouteContext = {
|
export type PlatformRouteContext = {
|
||||||
@@ -266,6 +287,7 @@ export type AdminSectionContribution = {
|
|||||||
order?: number;
|
order?: number;
|
||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
|
surfaceId?: string;
|
||||||
render: (context: AdminSectionRenderContext) => ReactNode;
|
render: (context: AdminSectionRenderContext) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -289,6 +311,7 @@ export type SettingsSectionContribution = {
|
|||||||
order?: number;
|
order?: number;
|
||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
|
surfaceId?: string;
|
||||||
render: (context: SettingsSectionRenderContext) => ReactNode;
|
render: (context: SettingsSectionRenderContext) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -301,6 +324,7 @@ export type PlatformRouteContribution = {
|
|||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
order?: number;
|
order?: number;
|
||||||
|
surfaceId?: string;
|
||||||
render: (context: PlatformRouteContext) => ReactNode;
|
render: (context: PlatformRouteContext) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -333,6 +357,53 @@ export type PlatformWebModule = {
|
|||||||
translations?: PlatformTranslations;
|
translations?: PlatformTranslations;
|
||||||
uiCapabilities?: PlatformUiCapabilities;
|
uiCapabilities?: PlatformUiCapabilities;
|
||||||
runtimeUiCapabilities?: 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";
|
export type DashboardWidgetSize = "small" | "medium" | "wide" | "full";
|
||||||
@@ -353,6 +424,7 @@ export type DashboardWidgetContribution = {
|
|||||||
moduleId?: string;
|
moduleId?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
surfaceId?: string;
|
||||||
defaultVisible?: boolean;
|
defaultVisible?: boolean;
|
||||||
defaultSize?: DashboardWidgetSize;
|
defaultSize?: DashboardWidgetSize;
|
||||||
supportedSizes?: DashboardWidgetSize[];
|
supportedSizes?: DashboardWidgetSize[];
|
||||||
@@ -383,6 +455,7 @@ export type OrganizationFunctionActionContribution = {
|
|||||||
order?: number;
|
order?: number;
|
||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
|
surfaceId?: string;
|
||||||
onClick: (context: OrganizationFunctionActionContext) => void;
|
onClick: (context: OrganizationFunctionActionContext) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -464,6 +537,51 @@ export type MailServerProfileCredentials = {
|
|||||||
imap?: MailTransportCredentials | null;
|
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 = {
|
export type MailServerProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id?: string | null;
|
tenant_id?: string | null;
|
||||||
@@ -473,11 +591,13 @@ export type MailServerProfile = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
inherit_to_lower_scopes?: boolean;
|
||||||
smtp: MailTransportSettings;
|
smtp: MailTransportSettings;
|
||||||
imap?: MailImapTransportSettings | null;
|
imap?: MailImapTransportSettings | null;
|
||||||
credentials?: MailServerProfileCredentials | null;
|
credentials?: MailServerProfileCredentials | null;
|
||||||
smtp_password_configured?: boolean;
|
smtp_password_configured?: boolean;
|
||||||
imap_password_configured?: boolean;
|
imap_password_configured?: boolean;
|
||||||
|
servers?: MailServerEndpoint[];
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
@@ -753,6 +873,7 @@ export type PlatformFrontendRouteInfo = {
|
|||||||
required_all: string[];
|
required_all: string[];
|
||||||
required_any: string[];
|
required_any: string[];
|
||||||
order: number;
|
order: number;
|
||||||
|
surface_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlatformFrontendModuleInfo = {
|
export type PlatformFrontendModuleInfo = {
|
||||||
@@ -777,8 +898,21 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
required_all: string[];
|
required_all: string[];
|
||||||
required_any: string[];
|
required_any: string[];
|
||||||
order: number;
|
order: number;
|
||||||
|
surface_id?: string | null;
|
||||||
}>;
|
}>;
|
||||||
settings_routes: PlatformFrontendRouteInfo[];
|
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 = {
|
export type PlatformPublicModuleInfo = {
|
||||||
@@ -814,6 +948,7 @@ export type PlatformModuleInfo = {
|
|||||||
required_all: string[];
|
required_all: string[];
|
||||||
required_any: string[];
|
required_any: string[];
|
||||||
order: number;
|
order: number;
|
||||||
|
surface_id?: string | null;
|
||||||
}>;
|
}>;
|
||||||
frontend?: PlatformFrontendModuleInfo | null;
|
frontend?: PlatformFrontendModuleInfo | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -161,5 +161,11 @@ export const adminReadScopes = [
|
|||||||
"access:system_role:read",
|
"access:system_role:read",
|
||||||
"access:audit:read",
|
"access:audit:read",
|
||||||
"access:system_setting: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,
|
routeContributionsForModules,
|
||||||
uiCapability
|
uiCapability
|
||||||
} from "../src/platform/moduleLogic";
|
} from "../src/platform/moduleLogic";
|
||||||
|
import {
|
||||||
|
isViewSurfaceVisible,
|
||||||
|
viewSurfaceCatalogueForModules,
|
||||||
|
visibleRoutesForProjection
|
||||||
|
} from "../src/platform/views";
|
||||||
import { scopeGrants } from "../src/utils/permissions";
|
import { scopeGrants } from "../src/utils/permissions";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): void {
|
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(selectedFunctionId === "function-1", "organization function actions receive their row context");
|
||||||
assert(!("render" in functionAction), "organization function actions expose metadata instead of arbitrary rendered controls");
|
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/module-capabilities.test.ts",
|
||||||
"tests/privacy-policy.test.ts",
|
"tests/privacy-policy.test.ts",
|
||||||
"tests/help-context.test.ts",
|
"tests/help-context.test.ts",
|
||||||
|
"tests/definition-graph.test.ts",
|
||||||
"src/platform/moduleLogic.ts",
|
"src/platform/moduleLogic.ts",
|
||||||
"src/utils/helpContext.ts",
|
"src/utils/helpContext.ts",
|
||||||
"src/features/privacy/policyLogic.ts",
|
"src/features/privacy/policyLogic.ts",
|
||||||
|
"src/definitionGraph.ts",
|
||||||
"src/types.ts"
|
"src/types.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const defaultWebModulePackages = [
|
|||||||
"@govoplan/audit-webui",
|
"@govoplan/audit-webui",
|
||||||
"@govoplan/calendar-webui",
|
"@govoplan/calendar-webui",
|
||||||
"@govoplan/campaign-webui",
|
"@govoplan/campaign-webui",
|
||||||
|
"@govoplan/dataflow-webui",
|
||||||
|
"@govoplan/datasources-webui",
|
||||||
"@govoplan/dashboard-webui",
|
"@govoplan/dashboard-webui",
|
||||||
"@govoplan/docs-webui",
|
"@govoplan/docs-webui",
|
||||||
"@govoplan/files-webui",
|
"@govoplan/files-webui",
|
||||||
@@ -26,7 +28,9 @@ const defaultWebModulePackages = [
|
|||||||
"@govoplan/organizations-webui",
|
"@govoplan/organizations-webui",
|
||||||
"@govoplan/ops-webui",
|
"@govoplan/ops-webui",
|
||||||
"@govoplan/policy-webui",
|
"@govoplan/policy-webui",
|
||||||
"@govoplan/scheduling-webui"
|
"@govoplan/scheduling-webui",
|
||||||
|
"@govoplan/views-webui",
|
||||||
|
"@govoplan/workflow-webui"
|
||||||
];
|
];
|
||||||
|
|
||||||
function configuredWebModulePackages(): string[] {
|
function configuredWebModulePackages(): string[] {
|
||||||
@@ -76,7 +80,10 @@ function govoplanInstalledModulesPlugin(): Plugin {
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [govoplanInstalledModulesPlugin(), react()],
|
plugins: [govoplanInstalledModulesPlugin(), react()],
|
||||||
optimizeDeps: {
|
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: {
|
build: {
|
||||||
// Full-product builds include the host shell plus all installed module wiring.
|
// Full-product builds include the host shell plus all installed module wiring.
|
||||||
@@ -85,9 +92,15 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
preserveSymlinks: true,
|
preserveSymlinks: true,
|
||||||
dedupe: ["react", "react-dom", "react-router-dom"],
|
dedupe: [
|
||||||
|
"@xyflow/react",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-router-dom"
|
||||||
|
],
|
||||||
alias: [
|
alias: [
|
||||||
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
|
{ 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)) }
|
{ 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-addresses/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-audit/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-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-dashboard/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-files/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-campaign/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-ops/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-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: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user