Release v0.1.2
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -326,3 +326,11 @@ cython_debug/
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
*.db
|
||||
|
||||
# GovOPlaN local runtime state
|
||||
runtime/
|
||||
|
||||
# GovOPlaN WebUI test output
|
||||
webui/.module-test-build/
|
||||
webui/.component-test-build/
|
||||
|
||||
48
AGENTS.md
Normal file
48
AGENTS.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# GovOPlaN Codex Guide
|
||||
|
||||
## Scope
|
||||
|
||||
This repository is the platform runner and shared core for GovOPlaN. It owns the server entry point, database/session primitives, auth, tenancy, RBAC, governance, module discovery, migrations, shared WebUI shell, and generic WebUI components.
|
||||
|
||||
Sibling module repositories usually used with this repo:
|
||||
|
||||
- `/mnt/DATA/git/govoplan-files`
|
||||
- `/mnt/DATA/git/govoplan-mail`
|
||||
- `/mnt/DATA/git/govoplan-campaign`
|
||||
|
||||
Keep module-specific behavior in the owning module. Core may expose stable extension points, capabilities, and shared components, but should not directly import module WebUI pages or require optional module packages for core-only startup.
|
||||
|
||||
## Local Commands
|
||||
|
||||
Use targeted commands first:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./.venv/bin/python -m govoplan_core.devserver --smoke --no-reload
|
||||
./.venv/bin/python -m unittest tests.test_module_system
|
||||
./.venv/bin/python -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
|
||||
```
|
||||
|
||||
For WebUI checks:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:mail-components
|
||||
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-capabilities
|
||||
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-permutations
|
||||
```
|
||||
|
||||
Run the consolidated focused check when a change touches module discovery, optional integrations, shared mail components, or mailbox listing:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./scripts/check-focused.sh
|
||||
```
|
||||
|
||||
## Working Rules
|
||||
|
||||
- Prefer `rg`, `sed`, and targeted tests over broad recursive scans or full builds.
|
||||
- Avoid DataGrid changes unless explicitly requested; it is intentionally brittle and has known deferred work.
|
||||
- Do not add module-to-module imports for optional integrations. Use core registry/capability/module metadata paths.
|
||||
- Do not keep generated WebUI test folders in git status; they should be ignored and removable.
|
||||
- Do not start persistent dev servers unless the user asks.
|
||||
26
README.md
26
README.md
@@ -22,6 +22,7 @@ Canonical policy documents live in `docs/`:
|
||||
- [RBAC_MANIFEST.md](docs/RBAC_MANIFEST.md)
|
||||
- [SYSTEM_GOVERNANCE_MANIFEST.md](docs/SYSTEM_GOVERNANCE_MANIFEST.md)
|
||||
- [MODULE_ARCHITECTURE.md](docs/MODULE_ARCHITECTURE.md)
|
||||
- [CODEX_WORKFLOW.md](docs/CODEX_WORKFLOW.md)
|
||||
|
||||
Modules may define module-specific permissions and policy behavior, but the platform-level permission model and governance hierarchy belong here.
|
||||
|
||||
@@ -34,20 +35,41 @@ cd /mnt/DATA/git/govoplan-core
|
||||
./.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 loads the access/core module only; product configs can enable installed modules.
|
||||
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 `access,campaigns,files,mail`; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./.venv/bin/python -m govoplan_core.devserver \
|
||||
--config app.govoplan_config:get_server_config \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000
|
||||
```
|
||||
|
||||
For example, to test campaign without files or mail:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
ENABLED_MODULES=access,campaigns ./.venv/bin/python -m govoplan_core.devserver \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000
|
||||
```
|
||||
|
||||
The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`.
|
||||
|
||||
The default development SQLite database lives at `runtime/multimailer-dev.db`, alongside other local runtime state.
|
||||
|
||||
Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running.
|
||||
|
||||
If the configured local SQLite database is missing or empty, `govoplan_core.devserver` enables the development bootstrap before loading settings. This creates the schema and the default development login on startup. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments should use migrations and managed database provisioning instead.
|
||||
|
||||
To verify the effective runtime paths and missing-SQLite bootstrap without starting uvicorn, run the smoke mode:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./.venv/bin/python -m govoplan_core.devserver --smoke --no-reload
|
||||
```
|
||||
|
||||
The smoke mode prints the effective config, runtime root, database URL, modules, reload state, and bootstrap decision, then creates the ASGI app and runs startup once.
|
||||
|
||||
`requirements-dev.txt` links local `govoplan-files`, `govoplan-mail`, and `govoplan-campaign` checkouts for development. `requirements-release.txt` installs those modules from tagged git refs for release builds. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
||||
|
||||
## WebUI development
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = sqlite:///./multimailer-dev.db
|
||||
sqlalchemy.url = sqlite:///./runtime/multimailer-dev.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""split mail profile usernames from server config
|
||||
|
||||
Revision ID: 0a1b2c3d4e6f
|
||||
Revises: f5a6b7c8d9e0
|
||||
Create Date: 2026-06-25 15:20:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0a1b2c3d4e6f"
|
||||
down_revision = "f5a6b7c8d9e0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _profiles_table() -> sa.Table:
|
||||
return sa.table(
|
||||
"mail_server_profiles",
|
||||
sa.column("id", sa.String(length=36)),
|
||||
sa.column("smtp_config", sa.JSON()),
|
||||
sa.column("smtp_username", sa.String(length=320)),
|
||||
sa.column("imap_config", sa.JSON()),
|
||||
sa.column("imap_username", sa.String(length=320)),
|
||||
)
|
||||
|
||||
|
||||
def _without_username(value):
|
||||
if not isinstance(value, dict):
|
||||
return value, None
|
||||
data = dict(value)
|
||||
username = data.pop("username", None)
|
||||
data.pop("enabled", None)
|
||||
return data, username
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "mail_server_profiles" not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
|
||||
if "smtp_username" not in columns:
|
||||
op.add_column("mail_server_profiles", sa.Column("smtp_username", sa.String(length=320), nullable=True))
|
||||
if "imap_username" not in columns:
|
||||
op.add_column("mail_server_profiles", sa.Column("imap_username", sa.String(length=320), nullable=True))
|
||||
|
||||
profiles = _profiles_table()
|
||||
rows = bind.execute(sa.select(profiles.c.id, profiles.c.smtp_config, profiles.c.smtp_username, profiles.c.imap_config, profiles.c.imap_username)).mappings().all()
|
||||
for row in rows:
|
||||
smtp_config, smtp_username = _without_username(row["smtp_config"] or {})
|
||||
imap_config, imap_username = _without_username(row["imap_config"] or {}) if row["imap_config"] is not None else (None, None)
|
||||
values = {
|
||||
"smtp_config": smtp_config,
|
||||
"imap_config": imap_config,
|
||||
}
|
||||
if row["smtp_username"] in (None, "") and smtp_username not in (None, ""):
|
||||
values["smtp_username"] = str(smtp_username)
|
||||
if row["imap_username"] in (None, "") and imap_username not in (None, ""):
|
||||
values["imap_username"] = str(imap_username)
|
||||
bind.execute(sa.update(profiles).where(profiles.c.id == row["id"]).values(**values))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "mail_server_profiles" not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
|
||||
profiles = _profiles_table()
|
||||
if {"smtp_username", "imap_username"}.issubset(columns):
|
||||
rows = bind.execute(sa.select(profiles.c.id, profiles.c.smtp_config, profiles.c.smtp_username, profiles.c.imap_config, profiles.c.imap_username)).mappings().all()
|
||||
for row in rows:
|
||||
smtp_config = dict(row["smtp_config"] or {})
|
||||
if row["smtp_username"] not in (None, ""):
|
||||
smtp_config["username"] = row["smtp_username"]
|
||||
imap_config = dict(row["imap_config"] or {}) if row["imap_config"] is not None else None
|
||||
if imap_config is not None and row["imap_username"] not in (None, ""):
|
||||
imap_config["username"] = row["imap_username"]
|
||||
bind.execute(sa.update(profiles).where(profiles.c.id == row["id"]).values(smtp_config=smtp_config, imap_config=imap_config))
|
||||
if "imap_username" in columns:
|
||||
op.drop_column("mail_server_profiles", "imap_username")
|
||||
if "smtp_username" in columns:
|
||||
op.drop_column("mail_server_profiles", "smtp_username")
|
||||
@@ -0,0 +1,108 @@
|
||||
"""drop mail credential override policy fields
|
||||
|
||||
Revision ID: 1b2c3d4e5f70
|
||||
Revises: 0a1b2c3d4e6f
|
||||
Create Date: 2026-06-25 18:30:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "1b2c3d4e5f70"
|
||||
down_revision = "0a1b2c3d4e6f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_MAIL_POLICY_KEY = "mail_profile_policy"
|
||||
_CREDENTIAL_KEYS = ("smtp_credentials", "imap_credentials")
|
||||
_DEPRECATED_LIMIT_KEYS = ("smtp_credentials.allow_override", "imap_credentials.allow_override")
|
||||
|
||||
|
||||
def _json_table(table_name: str, column_name: str) -> sa.Table:
|
||||
return sa.table(
|
||||
table_name,
|
||||
sa.column("id", sa.String(length=36)),
|
||||
sa.column(column_name, sa.JSON()),
|
||||
)
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any] | None:
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return dict(parsed) if isinstance(parsed, dict) else None
|
||||
return None
|
||||
|
||||
|
||||
def _scrub_policy(value: Any) -> tuple[dict[str, Any] | None, bool]:
|
||||
policy = _as_dict(value)
|
||||
if policy is None:
|
||||
return None, False
|
||||
changed = False
|
||||
|
||||
for key in _CREDENTIAL_KEYS:
|
||||
credential = _as_dict(policy.get(key))
|
||||
if credential is not None and "allow_override" in credential:
|
||||
credential.pop("allow_override", None)
|
||||
policy[key] = credential
|
||||
changed = True
|
||||
|
||||
limits = _as_dict(policy.get("allow_lower_level_limits"))
|
||||
if limits is not None:
|
||||
for key in _DEPRECATED_LIMIT_KEYS:
|
||||
if key in limits:
|
||||
limits.pop(key, None)
|
||||
changed = True
|
||||
if changed:
|
||||
policy["allow_lower_level_limits"] = limits
|
||||
|
||||
return policy, changed
|
||||
|
||||
|
||||
def _scrub_settings_table(table_name: str) -> None:
|
||||
bind = op.get_bind()
|
||||
table = _json_table(table_name, "settings")
|
||||
rows = bind.execute(sa.select(table.c.id, table.c.settings)).mappings().all()
|
||||
for row in rows:
|
||||
settings = _as_dict(row["settings"])
|
||||
if settings is None:
|
||||
continue
|
||||
policy, changed = _scrub_policy(settings.get(_MAIL_POLICY_KEY))
|
||||
if changed and policy is not None:
|
||||
settings[_MAIL_POLICY_KEY] = policy
|
||||
bind.execute(sa.update(table).where(table.c.id == row["id"]).values(settings=settings))
|
||||
|
||||
|
||||
def _scrub_policy_column(table_name: str) -> None:
|
||||
bind = op.get_bind()
|
||||
table = _json_table(table_name, "mail_profile_policy")
|
||||
rows = bind.execute(sa.select(table.c.id, table.c.mail_profile_policy)).mappings().all()
|
||||
for row in rows:
|
||||
policy, changed = _scrub_policy(row["mail_profile_policy"])
|
||||
if changed and policy is not None:
|
||||
bind.execute(sa.update(table).where(table.c.id == row["id"]).values(mail_profile_policy=policy))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
for table_name in ("system_settings", "tenants"):
|
||||
if table_name in tables and "settings" in {column["name"] for column in inspector.get_columns(table_name)}:
|
||||
_scrub_settings_table(table_name)
|
||||
for table_name in ("users", "groups", "campaigns"):
|
||||
if table_name in tables and "mail_profile_policy" in {column["name"] for column in inspector.get_columns(table_name)}:
|
||||
_scrub_policy_column(table_name)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The removed fields are redundant with the surviving lower-level limit
|
||||
# switches, so they cannot be reconstructed safely.
|
||||
pass
|
||||
73
docs/CODEX_WORKFLOW.md
Normal file
73
docs/CODEX_WORKFLOW.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Codex Workflow
|
||||
|
||||
This project is split across the core runner and sibling module repositories. Codex works best when all active repositories are writable from the start and routine checks use targeted commands.
|
||||
|
||||
## Personal Codex Config
|
||||
|
||||
Put machine-specific access in `~/.codex/config.toml`, not in a tracked project file:
|
||||
|
||||
```toml
|
||||
model = "gpt-5.5"
|
||||
model_reasoning_effort = "xhigh"
|
||||
personality = "pragmatic"
|
||||
|
||||
sandbox_mode = "workspace-write"
|
||||
approval_policy = "on-request"
|
||||
approvals_reviewer = "user"
|
||||
|
||||
[sandbox_workspace_write]
|
||||
writable_roots = [
|
||||
"/mnt/DATA/git",
|
||||
]
|
||||
network_access = false
|
||||
|
||||
[projects."/mnt/DATA/git/govoplan-core"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[projects."/mnt/DATA/git/govoplan-mail"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[projects."/mnt/DATA/git/govoplan-files"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[projects."/mnt/DATA/git/govoplan-campaign"]
|
||||
trust_level = "trusted"
|
||||
```
|
||||
|
||||
The broad writable root reduces approval churn. The explicit project trust entries allow project-local `AGENTS.md` guidance to load for each repository.
|
||||
|
||||
## Repository Guidance
|
||||
|
||||
Each active repository has an `AGENTS.md` file. These files define ownership, module boundaries, and focused commands for Codex. Keep durable project conventions there instead of repeating them in every prompt.
|
||||
|
||||
Use `~/.codex/config.toml` for personal defaults, auth/runtime settings, writable roots, and trust decisions. Avoid checking in absolute-path writable roots or model preferences unless they are intentionally team-wide.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
Use the consolidated script after changes that touch module discovery, optional integrations, shared mail components, mailbox listing, or cross-module WebUI behavior:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./scripts/check-focused.sh
|
||||
```
|
||||
|
||||
For smaller changes, prefer the narrow command named in the relevant `AGENTS.md` file. Examples:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./.venv/bin/python -m unittest tests.test_module_system
|
||||
|
||||
cd /mnt/DATA/git/govoplan-mail
|
||||
/mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
||||
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-permutations
|
||||
```
|
||||
|
||||
## Usage Discipline
|
||||
|
||||
- Prefer `rg`, `sed`, and targeted test commands.
|
||||
- Avoid broad recursive scans and full builds unless the change warrants them.
|
||||
- Keep generated build/test folders ignored.
|
||||
- Keep optional module behavior behind core registry/capability/module metadata boundaries.
|
||||
- Do not start persistent dev servers unless the user asks.
|
||||
@@ -166,13 +166,6 @@ cd /mnt/DATA/git/govoplan-core/webui
|
||||
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
||||
```
|
||||
|
||||
Wrapper WebUI verification, while the legacy wrapper still exists:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/multi-seal-mail-webui
|
||||
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
||||
```
|
||||
|
||||
Clean generated `dist`, `.vite`, and source-tree `__pycache__` artifacts after verification unless they are intentionally part of a release artifact.
|
||||
|
||||
## Release Dependency Rules
|
||||
|
||||
@@ -244,7 +244,7 @@ Recipient-complete campaign JSON, message data and job detail require `recipient
|
||||
| `mail_servers:write` | define/edit profiles in allowed scopes |
|
||||
| `mail_servers:manage_credentials` | create/replace SMTP/IMAP secrets or campaign-level credentials where policy allows |
|
||||
|
||||
Reusable encrypted profiles now exist. Effective usability is also constrained by hierarchical mail-profile policy, ownership, allowed/forced profile sets, credential inheritance/override rules and allow/deny patterns.
|
||||
Reusable encrypted profiles now exist. Effective usability is also constrained by hierarchical mail-profile policy, ownership, allowed/forced profile sets, credential inheritance mode, the lower-level override switch for that mode, and allow/deny patterns.
|
||||
|
||||
## Sessions, API Keys and CSRF
|
||||
|
||||
|
||||
@@ -48,10 +48,17 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
|
||||
|
||||
The module repositories include root-level npm package manifests so git installs can resolve `@govoplan/files-webui`, `@govoplan/mail-webui`, and `@govoplan/campaign-webui` from repository roots even though their source lives below `webui/src`.
|
||||
|
||||
### Release lockfile strategy
|
||||
|
||||
The supported release composition currently is the full Multi Seal Mail product: core plus files, mail, and campaign. Keep one committed full-product release lockfile at `webui/package-lock.release.json`, generated from `webui/package.release.json` in a clean release workspace. Development `package-lock.json` may continue to point at local `file:` dependencies.
|
||||
|
||||
Frontend module permutations are regression-tested through `GOVOPLAN_WEBUI_MODULE_PACKAGES` and temporary build output, not through committed lockfiles for every possible combination. If a smaller composition becomes a separately shipped product, add an explicit release manifest and lockfile pair for that product, for example `package.release.files-mail.json` and `package-lock.release.files-mail.json`, generated in a clean release workspace from tagged git dependencies.
|
||||
|
||||
## Release Checklist
|
||||
|
||||
- Keep Python package versions, WebUI package versions, and git tags aligned.
|
||||
- Tag core, files, mail, and campaign repositories together.
|
||||
- Update `requirements-release.txt` and `webui/package.release.json` when the release tag changes.
|
||||
- Generate release lockfiles from release manifests in a clean build workspace.
|
||||
- Generate the committed full-product release lockfile from `package.release.json` in a clean build workspace.
|
||||
- Add separate release manifest/lockfile pairs only for module compositions that are shipped as their own products.
|
||||
- Do not commit local sibling paths into release manifests.
|
||||
|
||||
@@ -82,7 +82,8 @@ Policy semantics:
|
||||
- forced profiles mean the lower level must choose from the forced set;
|
||||
- a forced set with one profile effectively enforces that profile;
|
||||
- campaign-level profile creation is allowed only if the effective policy permits it;
|
||||
- credentials may be inherited from the authoritative profile or supplied by a lower level only when override is allowed;
|
||||
- SMTP/IMAP credentials use one inheritance decision per protocol: lower levels must inherit profile credentials, may inherit profile credentials, or must provide local credentials;
|
||||
- the lower-level override switch for `smtp_credentials.inherit` and `imap_credentials.inherit` controls whether descendants may change that inheritance decision;
|
||||
- deny patterns always win over allow patterns;
|
||||
- empty or `*` allowlist means allow all except denied;
|
||||
- non-empty allowlist means at least one allow rule must match and no deny rule may match.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
60
scripts/check-focused.sh
Normal file
60
scripts/check-focused.sh
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="/mnt/DATA/git/govoplan-core"
|
||||
NODE="/home/zemion/.nvm/versions/node/v22.22.3/bin"
|
||||
NPM="$NODE/npm"
|
||||
WEBUI_BIN="$ROOT/webui/node_modules/.bin"
|
||||
|
||||
export PATH="$WEBUI_BIN:$NODE:$PATH"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
./.venv/bin/python - <<'PY'
|
||||
import ast
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
roots = [
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-core/src"),
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-mail/src"),
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-files/src"),
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-campaign/src"),
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-mail/tests"),
|
||||
]
|
||||
|
||||
errors = []
|
||||
count = 0
|
||||
for root in roots:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*.py"):
|
||||
count += 1
|
||||
try:
|
||||
ast.parse(path.read_text(), filename=str(path))
|
||||
except SyntaxError as exc:
|
||||
errors.append(f"{path}:{exc.lineno}:{exc.offset}: {exc.msg}")
|
||||
|
||||
if errors:
|
||||
print("\n".join(errors))
|
||||
sys.exit(1)
|
||||
|
||||
print(f"AST syntax check passed for {count} Python files")
|
||||
PY
|
||||
|
||||
./.venv/bin/python -c 'import govoplan_core.db.bootstrap; import govoplan_core.admin.service; import govoplan_files.backend.router; import govoplan_mail.backend.sending.imap; print("targeted backend imports passed")'
|
||||
./.venv/bin/python -m unittest tests.test_module_system
|
||||
./.venv/bin/python -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
|
||||
./.venv/bin/python -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
|
||||
|
||||
cd "$ROOT/webui"
|
||||
"$NPM" run test:mail-components
|
||||
"$NPM" run test:module-capabilities
|
||||
"$NPM" run test:module-permutations
|
||||
|
||||
cd /mnt/DATA/git/govoplan-mail/webui
|
||||
"$NPM" run test:mail-ui
|
||||
|
||||
cd /mnt/DATA/git/govoplan-campaign/webui
|
||||
"$NPM" run test:policy-ui
|
||||
"$NPM" run test:template-preview
|
||||
424
scripts/push-release-tag.sh
Normal file
424
scripts/push-release-tag.sh
Normal file
@@ -0,0 +1,424 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
scripts/push-release-tag.sh [options]
|
||||
|
||||
Bumps all GovOPlaN package versions, commits all changes in all four repos,
|
||||
creates an annotated release tag in each repo, and pushes branch+tag.
|
||||
|
||||
By default the script asks which version part to bump:
|
||||
major X.Y.Z -> (X+1).0.0
|
||||
minor X.Y.Z -> X.(Y+1).0
|
||||
subversion X.Y.Z -> X.Y.(Z+1)
|
||||
|
||||
Options:
|
||||
--bump <kind> Version bump: major, minor, subversion, or patch.
|
||||
--version <x.y.z> Explicit target version instead of a bump.
|
||||
-r, --remote <name> Remote to push to. Defaults to origin.
|
||||
-b, --branch <name> Branch to update in every repo. Defaults to each current branch.
|
||||
-m, --message <text> Commit message. Defaults to "Release v<x.y.z>".
|
||||
--tag-message <text> Annotated tag message. Defaults to the commit message.
|
||||
-y, --yes Do not prompt before committing, tagging, and pushing.
|
||||
-n, --dry-run Print intended actions without changing files or git state.
|
||||
-h, --help Show this help.
|
||||
|
||||
Repos:
|
||||
govoplan-core
|
||||
govoplan-files
|
||||
govoplan-mail
|
||||
govoplan-campaign
|
||||
|
||||
Examples:
|
||||
scripts/push-release-tag.sh
|
||||
scripts/push-release-tag.sh --bump subversion
|
||||
scripts/push-release-tag.sh --version 0.2.0 --message "Release v0.2.0"
|
||||
USAGE
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PARENT="$(dirname "$ROOT")"
|
||||
REPOS=(
|
||||
"$ROOT"
|
||||
"$PARENT/govoplan-files"
|
||||
"$PARENT/govoplan-mail"
|
||||
"$PARENT/govoplan-campaign"
|
||||
)
|
||||
|
||||
REMOTE="origin"
|
||||
BRANCH=""
|
||||
BUMP=""
|
||||
TARGET_VERSION=""
|
||||
COMMIT_MESSAGE=""
|
||||
TAG_MESSAGE=""
|
||||
DRY_RUN=0
|
||||
YES=0
|
||||
|
||||
if [[ -z "${PYTHON:-}" ]]; then
|
||||
if [[ -x "$ROOT/.venv/bin/python" ]]; then
|
||||
PYTHON="$ROOT/.venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
fi
|
||||
|
||||
normalize_bump() {
|
||||
local value="${1,,}"
|
||||
case "$value" in
|
||||
major|minor|subversion)
|
||||
printf '%s\n' "$value"
|
||||
;;
|
||||
patch|sub|sub-version)
|
||||
printf '%s\n' "subversion"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_version() {
|
||||
[[ "$1" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]
|
||||
}
|
||||
|
||||
version_gt() {
|
||||
local new_major new_minor new_patch old_major old_minor old_patch
|
||||
IFS=. read -r new_major new_minor new_patch <<< "$1"
|
||||
IFS=. read -r old_major old_minor old_patch <<< "$2"
|
||||
|
||||
(( new_major > old_major )) && return 0
|
||||
(( new_major < old_major )) && return 1
|
||||
(( new_minor > old_minor )) && return 0
|
||||
(( new_minor < old_minor )) && return 1
|
||||
(( new_patch > old_patch )) && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
bump_version() {
|
||||
local version="$1"
|
||||
local bump="$2"
|
||||
local major minor patch
|
||||
IFS=. read -r major minor patch <<< "$version"
|
||||
|
||||
case "$bump" in
|
||||
major)
|
||||
printf '%s.0.0\n' "$((major + 1))"
|
||||
;;
|
||||
minor)
|
||||
printf '%s.%s.0\n' "$major" "$((minor + 1))"
|
||||
;;
|
||||
subversion)
|
||||
printf '%s.%s.%s\n' "$major" "$minor" "$((patch + 1))"
|
||||
;;
|
||||
*)
|
||||
fail "unknown bump kind: $bump"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_project_name() {
|
||||
"$PYTHON" -c 'import pathlib, sys, tomllib; print(tomllib.loads(pathlib.Path(sys.argv[1]).read_text())["project"]["name"])' "$1/pyproject.toml"
|
||||
}
|
||||
|
||||
get_project_version() {
|
||||
"$PYTHON" -c 'import pathlib, sys, tomllib; print(tomllib.loads(pathlib.Path(sys.argv[1]).read_text())["project"]["version"])' "$1/pyproject.toml"
|
||||
}
|
||||
|
||||
update_pyproject() {
|
||||
local repo="$1"
|
||||
local version="$2"
|
||||
|
||||
"$PYTHON" - "$repo/pyproject.toml" "$version" <<'PYCODE'
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
new_version = sys.argv[2]
|
||||
text = path.read_text()
|
||||
data = tomllib.loads(text)
|
||||
project = data["project"]
|
||||
name = project["name"]
|
||||
old_version = project["version"]
|
||||
|
||||
text, version_count = re.subn(
|
||||
r'(?m)^version\s*=\s*["\'][^"\']+["\']\s*$',
|
||||
f'version = "{new_version}"',
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
if version_count != 1:
|
||||
raise SystemExit(f"could not update project.version in {path}")
|
||||
|
||||
if name != "govoplan-core":
|
||||
text, dependency_count = re.subn(
|
||||
r'govoplan-core>=\d+\.\d+\.\d+',
|
||||
f'govoplan-core>={new_version}',
|
||||
text,
|
||||
)
|
||||
if dependency_count < 1:
|
||||
raise SystemExit(f"could not update govoplan-core dependency in {path}")
|
||||
|
||||
path.write_text(text)
|
||||
print(f"{name}: {old_version} -> {new_version}")
|
||||
PYCODE
|
||||
}
|
||||
|
||||
print_command() {
|
||||
printf '+'
|
||||
printf ' %q' "$@"
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
run() {
|
||||
print_command "$@"
|
||||
if [[ "$DRY_RUN" -eq 0 ]]; then
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_for_bump() {
|
||||
local answer=""
|
||||
|
||||
if [[ -n "$BUMP" || -n "$TARGET_VERSION" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ ! -t 0 ]]; then
|
||||
fail "pass --bump major|minor|subversion or --version x.y.z for non-interactive use"
|
||||
fi
|
||||
|
||||
echo "Select version increase:"
|
||||
echo " 1) major X.Y.Z -> (X+1).0.0"
|
||||
echo " 2) minor X.Y.Z -> X.(Y+1).0"
|
||||
echo " 3) subversion X.Y.Z -> X.Y.(Z+1)"
|
||||
printf 'Choice [subversion]: '
|
||||
read -r answer
|
||||
|
||||
case "${answer,,}" in
|
||||
""|3|subversion|patch|sub|sub-version)
|
||||
BUMP="subversion"
|
||||
;;
|
||||
1|major)
|
||||
BUMP="major"
|
||||
;;
|
||||
2|minor)
|
||||
BUMP="minor"
|
||||
;;
|
||||
*)
|
||||
fail "unknown version bump: $answer"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
confirm_release() {
|
||||
local answer=""
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 || "$YES" -eq 1 ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ ! -t 0 ]]; then
|
||||
fail "refusing to commit, tag, and push without confirmation; pass --yes for non-interactive use"
|
||||
fi
|
||||
|
||||
printf 'Commit all changes, tag all repos, and push to %s? [y/N] ' "$REMOTE"
|
||||
read -r answer
|
||||
case "${answer,,}" in
|
||||
y|yes)
|
||||
;;
|
||||
*)
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--bump)
|
||||
[[ $# -ge 2 ]] || fail "missing value for $1"
|
||||
BUMP="$(normalize_bump "$2")" || fail "unknown bump kind: $2"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
[[ $# -ge 2 ]] || fail "missing value for $1"
|
||||
TARGET_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--remote)
|
||||
[[ $# -ge 2 ]] || fail "missing value for $1"
|
||||
REMOTE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--branch)
|
||||
[[ $# -ge 2 ]] || fail "missing value for $1"
|
||||
BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-m|--message)
|
||||
[[ $# -ge 2 ]] || fail "missing value for $1"
|
||||
COMMIT_MESSAGE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--tag-message)
|
||||
[[ $# -ge 2 ]] || fail "missing value for $1"
|
||||
TAG_MESSAGE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-y|--yes)
|
||||
YES=1
|
||||
shift
|
||||
;;
|
||||
-n|--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "$BUMP" && -n "$TARGET_VERSION" ]]; then
|
||||
fail "use either --bump or --version, not both"
|
||||
fi
|
||||
|
||||
prompt_for_bump
|
||||
|
||||
if ! command -v "$PYTHON" >/dev/null 2>&1; then
|
||||
fail "Python interpreter not found: $PYTHON"
|
||||
fi
|
||||
|
||||
declare -A PROJECT_NAMES
|
||||
declare -A OLD_VERSIONS
|
||||
declare -A BRANCHES
|
||||
|
||||
for repo in "${REPOS[@]}"; do
|
||||
[[ -d "$repo/.git" ]] || fail "not a git repo: $repo"
|
||||
[[ -f "$repo/pyproject.toml" ]] || fail "missing pyproject.toml: $repo"
|
||||
|
||||
PROJECT_NAMES["$repo"]="$(get_project_name "$repo")"
|
||||
OLD_VERSIONS["$repo"]="$(get_project_version "$repo")"
|
||||
|
||||
validate_version "${OLD_VERSIONS[$repo]}" || fail "unsupported version ${OLD_VERSIONS[$repo]} in $repo"
|
||||
|
||||
if [[ -n "$BRANCH" ]]; then
|
||||
BRANCHES["$repo"]="$BRANCH"
|
||||
else
|
||||
BRANCHES["$repo"]="$(git -C "$repo" symbolic-ref --quiet --short HEAD || true)"
|
||||
fi
|
||||
|
||||
[[ -n "${BRANCHES[$repo]}" ]] || fail "cannot infer branch for $repo; pass --branch <name>"
|
||||
git -C "$repo" check-ref-format "refs/heads/${BRANCHES[$repo]}" || fail "invalid branch name ${BRANCHES[$repo]} for $repo"
|
||||
git -C "$repo" remote get-url "$REMOTE" >/dev/null || fail "remote $REMOTE not found in $repo"
|
||||
|
||||
upstream="$(git -C "$repo" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)"
|
||||
if [[ -n "$upstream" ]]; then
|
||||
behind_count="$(git -C "$repo" rev-list --count "HEAD..$upstream")"
|
||||
[[ "$behind_count" == "0" ]] || fail "$repo is behind $upstream by $behind_count commit(s); pull/rebase first"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$TARGET_VERSION" ]]; then
|
||||
BASE_VERSION="${OLD_VERSIONS[$ROOT]}"
|
||||
for repo in "${REPOS[@]}"; do
|
||||
if [[ "${OLD_VERSIONS[$repo]}" != "$BASE_VERSION" ]]; then
|
||||
fail "repo versions differ; pass --version x.y.z to choose an explicit target"
|
||||
fi
|
||||
done
|
||||
TARGET_VERSION="$(bump_version "$BASE_VERSION" "$BUMP")"
|
||||
fi
|
||||
|
||||
validate_version "$TARGET_VERSION" || fail "target version must be x.y.z: $TARGET_VERSION"
|
||||
|
||||
for repo in "${REPOS[@]}"; do
|
||||
if ! version_gt "$TARGET_VERSION" "${OLD_VERSIONS[$repo]}"; then
|
||||
fail "target version $TARGET_VERSION must be greater than ${OLD_VERSIONS[$repo]} for ${PROJECT_NAMES[$repo]}"
|
||||
fi
|
||||
done
|
||||
|
||||
TAG="v$TARGET_VERSION"
|
||||
COMMIT_MESSAGE="${COMMIT_MESSAGE:-Release $TAG}"
|
||||
TAG_MESSAGE="${TAG_MESSAGE:-$COMMIT_MESSAGE}"
|
||||
|
||||
git -C "$ROOT" check-ref-format "refs/tags/$TAG" || fail "invalid tag name: $TAG"
|
||||
|
||||
for repo in "${REPOS[@]}"; do
|
||||
if git -C "$repo" rev-parse --quiet --verify "refs/tags/$TAG" >/dev/null; then
|
||||
fail "local tag already exists in ${PROJECT_NAMES[$repo]}: $TAG"
|
||||
fi
|
||||
|
||||
set +e
|
||||
git -C "$repo" ls-remote --exit-code --tags "$REMOTE" "refs/tags/$TAG" >/dev/null
|
||||
ls_remote_status=$?
|
||||
set -e
|
||||
|
||||
if [[ "$ls_remote_status" -eq 0 ]]; then
|
||||
fail "remote tag already exists for ${PROJECT_NAMES[$repo]} on $REMOTE: $TAG"
|
||||
elif [[ "$ls_remote_status" -ne 2 ]]; then
|
||||
fail "could not check remote tags for ${PROJECT_NAMES[$repo]} on $REMOTE"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Release plan:"
|
||||
echo " version: $TARGET_VERSION"
|
||||
echo " tag: $TAG"
|
||||
echo " remote: $REMOTE"
|
||||
echo " commit message: $COMMIT_MESSAGE"
|
||||
echo
|
||||
|
||||
for repo in "${REPOS[@]}"; do
|
||||
echo "${PROJECT_NAMES[$repo]}: ${OLD_VERSIONS[$repo]} -> $TARGET_VERSION on ${BRANCHES[$repo]}"
|
||||
git -C "$repo" status --short
|
||||
echo
|
||||
done
|
||||
|
||||
confirm_release
|
||||
|
||||
for repo in "${REPOS[@]}"; do
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
echo "Would update $repo/pyproject.toml to $TARGET_VERSION"
|
||||
else
|
||||
update_pyproject "$repo" "$TARGET_VERSION"
|
||||
fi
|
||||
done
|
||||
|
||||
for repo in "${REPOS[@]}"; do
|
||||
run git -C "$repo" add -A
|
||||
|
||||
if [[ "$DRY_RUN" -eq 0 ]]; then
|
||||
if git -C "$repo" diff --cached --quiet; then
|
||||
fail "no staged changes for ${PROJECT_NAMES[$repo]} after version bump"
|
||||
fi
|
||||
fi
|
||||
|
||||
run git -C "$repo" commit -m "$COMMIT_MESSAGE"
|
||||
run git -C "$repo" tag -a "$TAG" -m "$TAG_MESSAGE"
|
||||
done
|
||||
|
||||
for repo in "${REPOS[@]}"; do
|
||||
run git -C "$repo" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$repo]}" "refs/tags/$TAG"
|
||||
done
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
echo "Dry run complete for $TAG across all GovOPlaN repos."
|
||||
else
|
||||
echo "Released $TAG across all GovOPlaN repos."
|
||||
fi
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.service import AdminConflictError, AdminValidationError, slugify
|
||||
from govoplan_core.core.optional import reraise_unless_missing_package
|
||||
from govoplan_core.db.models import (
|
||||
ApiKey,
|
||||
GovernanceTemplate,
|
||||
@@ -17,7 +18,6 @@ from govoplan_core.db.models import (
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
|
||||
from govoplan_core.security.permissions import validate_tenant_permissions
|
||||
|
||||
SYSTEM_SETTINGS_ID = "global"
|
||||
@@ -25,6 +25,15 @@ TEMPLATE_KINDS = {"group", "role"}
|
||||
ASSIGNMENT_MODES = {"available", "required"}
|
||||
|
||||
|
||||
def _file_models():
|
||||
try:
|
||||
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
|
||||
except ModuleNotFoundError as exc:
|
||||
reraise_unless_missing_package(exc, "govoplan_files")
|
||||
return None
|
||||
return FileAsset, FileFolder, FileShare
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectiveTenantGovernance:
|
||||
allow_custom_groups: bool
|
||||
@@ -246,11 +255,17 @@ def _remove_materialized_template(session: Session, item: GovernanceTemplate, te
|
||||
return
|
||||
membership_count = session.query(UserGroupMembership).filter(UserGroupMembership.group_id == group.id).count()
|
||||
role_count = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.group_id == group.id).count()
|
||||
owned_asset_count = session.query(FileAsset).filter(FileAsset.owner_group_id == group.id).count()
|
||||
owned_folder_count = session.query(FileFolder).filter(FileFolder.owner_group_id == group.id).count()
|
||||
shared_asset_count = session.query(FileShare).filter(
|
||||
FileShare.target_type == "group", FileShare.target_id == group.id
|
||||
).count()
|
||||
file_models = _file_models()
|
||||
owned_asset_count = 0
|
||||
owned_folder_count = 0
|
||||
shared_asset_count = 0
|
||||
if file_models is not None:
|
||||
FileAsset, FileFolder, FileShare = file_models
|
||||
owned_asset_count = session.query(FileAsset).filter(FileAsset.owner_group_id == group.id).count()
|
||||
owned_folder_count = session.query(FileFolder).filter(FileFolder.owner_group_id == group.id).count()
|
||||
shared_asset_count = session.query(FileShare).filter(
|
||||
FileShare.target_type == "group", FileShare.target_id == group.id
|
||||
).count()
|
||||
if membership_count or role_count or owned_asset_count or owned_folder_count or shared_asset_count:
|
||||
raise AdminConflictError(
|
||||
f"Cannot remove {item.name!r} from the tenant while its managed group has members, roles, files, folders or shares."
|
||||
|
||||
@@ -9,6 +9,7 @@ from dataclasses import dataclass
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.optional import reraise_unless_missing_package
|
||||
from govoplan_core.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
@@ -548,16 +549,25 @@ def role_assignment_counts(session: Session, role_id: str) -> tuple[int, int]:
|
||||
)
|
||||
|
||||
|
||||
def _optional_model(module_name: str, model_name: str):
|
||||
try:
|
||||
module = __import__(module_name, fromlist=[model_name])
|
||||
except ModuleNotFoundError as exc:
|
||||
reraise_unless_missing_package(exc, module_name.split(".", 1)[0])
|
||||
return None
|
||||
return getattr(module, model_name)
|
||||
|
||||
|
||||
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_files.backend.db.models import FileAsset
|
||||
Campaign = _optional_model("govoplan_campaign.backend.db.models", "Campaign")
|
||||
FileAsset = _optional_model("govoplan_files.backend.db.models", "FileAsset")
|
||||
|
||||
return {
|
||||
"users": session.query(User).filter(User.tenant_id == tenant_id).count(),
|
||||
"active_users": session.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(),
|
||||
"groups": session.query(Group).filter(Group.tenant_id == tenant_id).count(),
|
||||
"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count(),
|
||||
"files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count(),
|
||||
"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count() if Campaign is not None else 0,
|
||||
"files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count() if FileAsset is not None else 0,
|
||||
"api_keys": session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -317,6 +317,7 @@ class PolicySourceStepItem(BaseModel):
|
||||
scope_id: str | None = None
|
||||
label: str
|
||||
applied_fields: list[str] = Field(default_factory=list)
|
||||
policy: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyItem(BaseModel):
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from govoplan_core.auth.dependencies import ApiPrincipal, require_any_scope
|
||||
from govoplan_core.core.optional import module_not_found_for
|
||||
|
||||
router = APIRouter(prefix="/schemas", tags=["schemas"])
|
||||
|
||||
@@ -13,12 +14,22 @@ def _load_campaign_schema() -> dict[str, Any]:
|
||||
try:
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_schema
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name == "govoplan_campaign" or exc.name.startswith("govoplan_campaign."):
|
||||
if module_not_found_for(exc, "govoplan_campaign"):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign module is not installed") from exc
|
||||
raise
|
||||
return load_campaign_schema()
|
||||
|
||||
|
||||
def _load_campaign_schema_ui() -> dict[str, Any]:
|
||||
try:
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_schema_ui
|
||||
except ModuleNotFoundError as exc:
|
||||
if module_not_found_for(exc, "govoplan_campaign"):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign module is not installed") from exc
|
||||
raise
|
||||
return load_campaign_schema_ui()
|
||||
|
||||
|
||||
@router.get("/campaign")
|
||||
def get_campaign_schema(
|
||||
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
|
||||
@@ -27,3 +38,14 @@ def get_campaign_schema(
|
||||
|
||||
del principal
|
||||
return _load_campaign_schema()
|
||||
|
||||
|
||||
@router.get("/campaign/ui")
|
||||
def get_campaign_schema_ui(
|
||||
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
|
||||
) -> dict[str, Any]:
|
||||
"""Return UI metadata paired with the campaign JSON Schema."""
|
||||
|
||||
del principal
|
||||
return _load_campaign_schema_ui()
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ def discover_module_manifests(
|
||||
enabled = set(enabled_modules) if enabled_modules is not None else None
|
||||
manifests: list[ModuleManifest] = []
|
||||
for entry_point in iter_module_entry_points(group):
|
||||
if enabled is not None and entry_point.name not in enabled:
|
||||
continue
|
||||
manifest = _load_manifest(entry_point)
|
||||
if enabled is not None and manifest.id not in enabled:
|
||||
continue
|
||||
|
||||
@@ -104,6 +104,7 @@ class ResourceAclProvider(Protocol):
|
||||
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
||||
DeleteVetoProvider = Callable[[object, str, str], None]
|
||||
RouteFactory = Callable[[ModuleContext], "APIRouter"]
|
||||
CapabilityFactory = Callable[[ModuleContext], object]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -122,4 +123,5 @@ class ModuleManifest:
|
||||
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
|
||||
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
||||
|
||||
|
||||
13
src/govoplan_core/core/optional.py
Normal file
13
src/govoplan_core/core/optional.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def module_not_found_for(exc: ModuleNotFoundError, package: str) -> bool:
|
||||
"""Return true when a ModuleNotFoundError belongs to an optional package."""
|
||||
|
||||
missing = exc.name or ""
|
||||
return missing == package or missing.startswith(f"{package}.")
|
||||
|
||||
|
||||
def reraise_unless_missing_package(exc: ModuleNotFoundError, package: str) -> None:
|
||||
if not module_not_found_for(exc, package):
|
||||
raise exc
|
||||
@@ -6,7 +6,9 @@ from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityFactory,
|
||||
DeleteVetoProvider,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
@@ -36,6 +38,9 @@ class PlatformRegistry:
|
||||
self._manifests: dict[str, ModuleManifest] = {}
|
||||
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
||||
self._delete_veto_providers: dict[str, list[DeleteVetoProvider]] = defaultdict(list)
|
||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||
self._capabilities: dict[str, object] = {}
|
||||
self._capability_context: ModuleContext | None = None
|
||||
|
||||
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
||||
if manifest.id in self._manifests:
|
||||
@@ -46,6 +51,8 @@ class PlatformRegistry:
|
||||
for resource_type, providers in manifest.delete_veto_providers.items():
|
||||
for provider in providers:
|
||||
self.register_delete_veto(resource_type, provider)
|
||||
for name, factory in manifest.capability_factories.items():
|
||||
self.register_capability_factory(manifest.id, name, factory)
|
||||
return manifest
|
||||
|
||||
def get(self, module_id: str) -> ModuleManifest | None:
|
||||
@@ -79,6 +86,36 @@ class PlatformRegistry:
|
||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
||||
|
||||
def configure_capability_context(self, context: ModuleContext) -> None:
|
||||
self._capability_context = context
|
||||
self._capabilities.clear()
|
||||
|
||||
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
|
||||
if name in self._capability_factories:
|
||||
raise RegistryError(f"Duplicate capability: {name}")
|
||||
self._capability_factories[name] = factory
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self._capability_factories
|
||||
|
||||
def capability(self, name: str) -> object | None:
|
||||
if name in self._capabilities:
|
||||
return self._capabilities[name]
|
||||
factory = self._capability_factories.get(name)
|
||||
if factory is None:
|
||||
return None
|
||||
if self._capability_context is None:
|
||||
raise RegistryError(f"Capability context is not configured: {name}")
|
||||
capability = factory(self._capability_context)
|
||||
self._capabilities[name] = capability
|
||||
return capability
|
||||
|
||||
def require_capability(self, name: str) -> object:
|
||||
capability = self.capability(name)
|
||||
if capability is None:
|
||||
raise RegistryError(f"Required capability is not available: {name}")
|
||||
return capability
|
||||
|
||||
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
|
||||
self._tenant_summary_providers[module_id] = provider
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.service import ensure_default_roles, get_or_create_account
|
||||
from govoplan_core.core.optional import reraise_unless_missing_package
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.models import Role, SystemRoleAssignment, Tenant, User, UserRoleAssignment
|
||||
from govoplan_core.db.session import get_database
|
||||
@@ -21,15 +22,26 @@ class BootstrapResult:
|
||||
created_api_key: CreatedApiKey | None
|
||||
|
||||
|
||||
def _import_optional_models(module_name: str) -> None:
|
||||
try:
|
||||
__import__(module_name)
|
||||
except ModuleNotFoundError as exc:
|
||||
reraise_unless_missing_package(exc, module_name.split(".", 1)[0])
|
||||
|
||||
|
||||
def create_all_tables() -> None:
|
||||
# Import models so SQLAlchemy sees all tables.
|
||||
# Import models so SQLAlchemy sees all tables from installed modules.
|
||||
from govoplan_core.db import models # noqa: F401
|
||||
from govoplan_core.access.db import models as access_models # noqa: F401
|
||||
from govoplan_files.backend.db import models as file_models # noqa: F401
|
||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401
|
||||
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401
|
||||
from govoplan_core.access.db.base import AccessBase
|
||||
|
||||
for module_name in (
|
||||
"govoplan_files.backend.db.models",
|
||||
"govoplan_mail.backend.db.models",
|
||||
"govoplan_campaign.backend.db.models",
|
||||
):
|
||||
_import_optional_models(module_name)
|
||||
|
||||
engine = get_database().engine
|
||||
Base.metadata.create_all(bind=engine)
|
||||
AccessBase.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
||||
from govoplan_core.server.default_config import get_server_config
|
||||
@@ -23,6 +23,7 @@ from govoplan_core.settings import settings
|
||||
REVISION_AUTH_RBAC = "2c3d4e5f6a7b"
|
||||
REVISION_FILE_STORAGE = "3d4e5f6a7b8c"
|
||||
REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
|
||||
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
|
||||
|
||||
_FILE_STORAGE_TABLES = {
|
||||
"file_blobs",
|
||||
@@ -69,6 +70,61 @@ _FILE_STORAGE_COLUMNS = {
|
||||
"file_folders": {"id", "tenant_id", "owner_type", "path"},
|
||||
}
|
||||
|
||||
_CREATE_ALL_THROUGH_HIERARCHICAL_TABLES = {
|
||||
"accounts",
|
||||
"auth_sessions",
|
||||
"audit_log",
|
||||
"campaign_versions",
|
||||
"campaign_jobs",
|
||||
"send_attempts",
|
||||
"system_role_assignments",
|
||||
"system_settings",
|
||||
"governance_templates",
|
||||
"governance_template_assignments",
|
||||
"mail_server_profiles",
|
||||
}
|
||||
|
||||
_CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
|
||||
"auth_sessions": {"account_id", "csrf_token_hash"},
|
||||
"audit_log": {"scope", "tenant_id", "user_id", "api_key_id"},
|
||||
"campaign_versions": {
|
||||
"workflow_state",
|
||||
"current_flow",
|
||||
"user_lock_state",
|
||||
"user_locked_at",
|
||||
"user_locked_by_user_id",
|
||||
"execution_snapshot",
|
||||
"execution_snapshot_hash",
|
||||
"execution_snapshot_at",
|
||||
},
|
||||
"campaign_jobs": {"claimed_at", "claim_token", "smtp_started_at", "outcome_unknown_at", "eml_sha256"},
|
||||
"send_attempts": {"status", "claim_token"},
|
||||
"users": {"account_id", "settings", "mail_profile_policy"},
|
||||
"groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
|
||||
"roles": {"system_template_id", "system_required"},
|
||||
"tenants": {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"},
|
||||
"campaigns": {"settings", "mail_profile_policy"},
|
||||
"mail_server_profiles": {"scope_type", "scope_id"},
|
||||
"system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
|
||||
}
|
||||
|
||||
_CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
|
||||
"file_folders": {"uq_file_folders_active_user_path", "uq_file_folders_active_group_path"},
|
||||
"campaign_versions": {
|
||||
"ix_campaign_versions_user_lock_state",
|
||||
"ix_campaign_versions_user_locked_by_user_id",
|
||||
"ix_campaign_versions_execution_snapshot_hash",
|
||||
},
|
||||
"campaign_jobs": {"ix_campaign_jobs_claim_token", "ix_campaign_jobs_eml_sha256"},
|
||||
"send_attempts": {"ix_send_attempts_status", "ix_send_attempts_claim_token"},
|
||||
"audit_log": {"ix_audit_log_scope_created_at", "ix_audit_log_tenant_scope_created_at"},
|
||||
"auth_sessions": {"ix_auth_sessions_account_id"},
|
||||
"users": {"ix_users_account_id"},
|
||||
"groups": {"ix_groups_system_template_id"},
|
||||
"roles": {"ix_roles_system_template_id"},
|
||||
"mail_server_profiles": {"ix_mail_server_profiles_scope_type", "ix_mail_server_profiles_scope_id", "ix_mail_server_profiles_scope"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MigrationResult:
|
||||
@@ -123,8 +179,44 @@ def _has_columns(inspector, table_name: str, required: set[str]) -> bool:
|
||||
return required.issubset(actual)
|
||||
|
||||
|
||||
def _has_indexes(inspector, table_name: str, required: set[str]) -> bool:
|
||||
try:
|
||||
actual = {index["name"] for index in inspector.get_indexes(table_name)}
|
||||
except Exception:
|
||||
return False
|
||||
return required.issubset(actual)
|
||||
|
||||
|
||||
def _has_create_all_schema_through_hierarchical_settings(inspector, tables: set[str]) -> bool:
|
||||
if not _CREATE_ALL_THROUGH_HIERARCHICAL_TABLES.issubset(tables):
|
||||
return False
|
||||
for table_name, required_columns in _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS.items():
|
||||
if table_name not in tables or not _has_columns(inspector, table_name, required_columns):
|
||||
return False
|
||||
for table_name, required_indexes in _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES.items():
|
||||
if table_name not in tables or not _has_indexes(inspector, table_name, required_indexes):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
|
||||
engine = create_engine(database_url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("""
|
||||
UPDATE campaign_versions
|
||||
SET user_lock_state = 'permanent',
|
||||
user_locked_at = published_at,
|
||||
user_locked_by_user_id = NULL
|
||||
WHERE published_at IS NOT NULL
|
||||
AND user_lock_state IS NULL
|
||||
"""))
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str | None:
|
||||
"""Repair the known Alembic/create_all drift without modifying table data.
|
||||
"""Repair the known Alembic/create_all drift without modifying application data.
|
||||
|
||||
Returns the revision stamped during reconciliation, or ``None`` when no
|
||||
repair was necessary. A partial/unknown schema is intentionally left alone
|
||||
@@ -148,6 +240,7 @@ def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str |
|
||||
"file_folders",
|
||||
_FILE_STORAGE_COLUMNS["file_folders"],
|
||||
)
|
||||
has_create_all_hierarchical_schema = _has_create_all_schema_through_hierarchical_settings(schema, tables)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
@@ -158,6 +251,17 @@ def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str |
|
||||
target = REVISION_FILE_STORAGE
|
||||
elif current == REVISION_FILE_STORAGE and has_file_folders:
|
||||
target = REVISION_FILE_FOLDERS
|
||||
elif current == REVISION_FILE_FOLDERS and has_create_all_hierarchical_schema:
|
||||
# Development DBs may be stamped at 4e after earlier create_all
|
||||
# reconciliation even though later tables/columns were already created
|
||||
# by a newer model set. Skip only when that complete known schema is
|
||||
# present, then let newer migrations such as mail credential usernames
|
||||
# run normally.
|
||||
_backfill_user_lock_state_for_create_all_schema(url)
|
||||
target = REVISION_HIERARCHICAL_SETTINGS
|
||||
elif current is None and has_create_all_hierarchical_schema:
|
||||
_backfill_user_lock_state_for_create_all_schema(url)
|
||||
target = REVISION_HIERARCHICAL_SETTINGS
|
||||
elif current is None and has_file_storage and has_file_folders:
|
||||
# This is the other create_all-only development shape. The strict
|
||||
# column checks above ensure that we only stamp a complete known schema.
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
from dataclasses import dataclass
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
@@ -19,6 +20,17 @@ DEFAULT_APP = "govoplan_core.server.app:app"
|
||||
DEFAULT_CONFIG = "govoplan_core.server.default_config:get_server_config"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DevserverState:
|
||||
config_path: str | None
|
||||
runtime_root: Path | None
|
||||
database_url: str
|
||||
bootstrap_db_path: Path | None
|
||||
config: GovoplanServerConfig
|
||||
registry: PlatformRegistry
|
||||
reload_dirs: list[str]
|
||||
|
||||
|
||||
def _config_module_runtime_root(config_path: str | None) -> Path | None:
|
||||
object_path = config_path or os.getenv("GOVOPLAN_SERVER_CONFIG")
|
||||
if not object_path:
|
||||
@@ -42,12 +54,13 @@ def _config_module_runtime_root(config_path: str | None) -> Path | None:
|
||||
|
||||
|
||||
def apply_runtime_defaults(config_path: str | None) -> Path | None:
|
||||
runtime_root = _config_module_runtime_root(config_path)
|
||||
if runtime_root is None:
|
||||
return None
|
||||
runtime_root = _config_module_runtime_root(config_path) or Path.cwd().resolve()
|
||||
(runtime_root / "runtime").mkdir(parents=True, exist_ok=True)
|
||||
(runtime_root / "runtime" / "files").mkdir(parents=True, exist_ok=True)
|
||||
(runtime_root / "runtime" / "mock-mailbox").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
defaults = {
|
||||
"DATABASE_URL": f"sqlite:///{runtime_root / 'multimailer-dev.db'}",
|
||||
"DATABASE_URL": f"sqlite:///{runtime_root / 'runtime' / 'multimailer-dev.db'}",
|
||||
"FILE_STORAGE_LOCAL_ROOT": str(runtime_root / "runtime" / "files"),
|
||||
"MOCK_MAILBOX_DIR": str(runtime_root / "runtime" / "mock-mailbox"),
|
||||
}
|
||||
@@ -56,6 +69,55 @@ def apply_runtime_defaults(config_path: str | None) -> Path | None:
|
||||
return runtime_root
|
||||
|
||||
|
||||
def _sqlite_database_path(database_url: str) -> Path | None:
|
||||
if not database_url.startswith("sqlite:///"):
|
||||
return None
|
||||
raw_path = database_url[len("sqlite:///"):]
|
||||
if not raw_path or raw_path == ":memory:":
|
||||
return None
|
||||
path = Path(raw_path).expanduser()
|
||||
return path if path.is_absolute() else Path.cwd() / path
|
||||
|
||||
|
||||
def validate_sqlite_database_url(database_url: str) -> None:
|
||||
db_path = _sqlite_database_path(database_url)
|
||||
if db_path is None:
|
||||
return
|
||||
if db_path.name.startswith(".fuse"):
|
||||
raise SystemExit(
|
||||
"DATABASE_URL points at a FUSE-hidden SQLite file. "
|
||||
"Stop the process still holding the deleted database, unset DATABASE_URL, "
|
||||
"or set DATABASE_URL to a normal .db path under the core runtime directory."
|
||||
)
|
||||
if not db_path.parent.exists():
|
||||
raise SystemExit(
|
||||
f"DATABASE_URL points to {db_path}, but its parent directory does not exist. "
|
||||
"Unset stale DATABASE_URL or set it to "
|
||||
f"sqlite:///{Path.cwd().resolve() / 'runtime' / 'multimailer-dev.db'}."
|
||||
)
|
||||
|
||||
|
||||
def _env_truthy(value: str | None) -> bool:
|
||||
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def enable_dev_bootstrap_for_missing_sqlite(database_url: str) -> Path | None:
|
||||
db_path = _sqlite_database_path(database_url)
|
||||
if db_path is None or db_path.name.startswith(".fuse") or not db_path.parent.exists():
|
||||
return None
|
||||
if db_path.exists() and db_path.stat().st_size > 0:
|
||||
return None
|
||||
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "true")
|
||||
return db_path
|
||||
|
||||
|
||||
def apply_detected_dev_bootstrap(config: GovoplanServerConfig, bootstrap_db_path: Path | None) -> None:
|
||||
if bootstrap_db_path is None or not _env_truthy(os.getenv("DEV_BOOTSTRAP_ENABLED")):
|
||||
return
|
||||
if config.settings is not None and hasattr(config.settings, "dev_bootstrap_enabled"):
|
||||
setattr(config.settings, "dev_bootstrap_enabled", True)
|
||||
|
||||
|
||||
def _source_root_for_path(path: str | os.PathLike[str]) -> Path | None:
|
||||
source_path = Path(path).resolve()
|
||||
directory = source_path if source_path.is_dir() else source_path.parent
|
||||
@@ -177,31 +239,86 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.")
|
||||
parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.")
|
||||
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
|
||||
parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[str] = ()) -> DevserverState:
|
||||
runtime_root = apply_runtime_defaults(config_path)
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
validate_sqlite_database_url(database_url)
|
||||
bootstrap_db_path = enable_dev_bootstrap_for_missing_sqlite(database_url)
|
||||
config = load_server_config(config_path)
|
||||
database_url = str(getattr(config.settings, "database_url", database_url) or "")
|
||||
validate_sqlite_database_url(database_url)
|
||||
if bootstrap_db_path is None:
|
||||
bootstrap_db_path = enable_dev_bootstrap_for_missing_sqlite(database_url)
|
||||
apply_detected_dev_bootstrap(config, bootstrap_db_path)
|
||||
registry = build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
|
||||
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=extra_reload_dirs)
|
||||
return DevserverState(
|
||||
config_path=config_path,
|
||||
runtime_root=runtime_root,
|
||||
database_url=database_url,
|
||||
bootstrap_db_path=bootstrap_db_path,
|
||||
config=config,
|
||||
registry=registry,
|
||||
reload_dirs=reload_dirs,
|
||||
)
|
||||
|
||||
|
||||
def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool) -> None:
|
||||
print(f"Starting GovOPlaN dev server: {app}")
|
||||
print(f"Config: {state.config_path or DEFAULT_CONFIG}")
|
||||
print(f"Runtime root: {state.runtime_root}")
|
||||
if state.database_url:
|
||||
print(f"Database: {state.database_url}")
|
||||
if state.bootstrap_db_path is not None:
|
||||
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
|
||||
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")
|
||||
print("Modules: " + ", ".join(manifest.id for manifest in state.registry.manifests()))
|
||||
if no_reload:
|
||||
print("Reload: disabled")
|
||||
else:
|
||||
print("Reload dirs:")
|
||||
for directory in state.reload_dirs:
|
||||
print(f" - {directory}")
|
||||
|
||||
|
||||
def run_smoke_check(state: DevserverState) -> None:
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
except ModuleNotFoundError as exc:
|
||||
raise SystemExit("fastapi.testclient is not available. Install govoplan-core dev/server requirements before running --smoke.") from exc
|
||||
|
||||
from govoplan_core.server.app import create_app
|
||||
|
||||
print("Smoke check: creating ASGI app and running startup")
|
||||
app = create_app(state.config)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/health")
|
||||
if response.status_code != 200:
|
||||
raise SystemExit(f"Smoke check failed: /health returned {response.status_code}: {response.text}")
|
||||
|
||||
db_path = _sqlite_database_path(state.database_url)
|
||||
if state.bootstrap_db_path is not None:
|
||||
if db_path is None or not db_path.exists() or db_path.stat().st_size <= 0:
|
||||
raise SystemExit(f"Smoke check failed: expected initialized SQLite DB at {state.bootstrap_db_path}")
|
||||
print("Smoke check: OK")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
if args.config:
|
||||
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
|
||||
|
||||
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
|
||||
runtime_root = apply_runtime_defaults(config_path)
|
||||
config = load_server_config(config_path)
|
||||
registry = build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
|
||||
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=args.reload_dir)
|
||||
state = prepare_devserver(config_path, extra_reload_dirs=args.reload_dir)
|
||||
print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
|
||||
|
||||
print(f"Starting GovOPlaN dev server: {args.app}")
|
||||
print(f"Config: {config_path or DEFAULT_CONFIG}")
|
||||
if runtime_root is not None:
|
||||
print(f"Runtime root: {runtime_root}")
|
||||
print("Modules: " + ", ".join(manifest.id for manifest in registry.manifests()))
|
||||
if args.no_reload:
|
||||
print("Reload: disabled")
|
||||
else:
|
||||
print("Reload dirs:")
|
||||
for directory in reload_dirs:
|
||||
print(f" - {directory}")
|
||||
if args.smoke:
|
||||
run_smoke_check(state)
|
||||
return 0
|
||||
|
||||
try:
|
||||
import uvicorn
|
||||
@@ -213,7 +330,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=not args.no_reload,
|
||||
reload_dirs=reload_dirs if not args.no_reload else None,
|
||||
reload_dirs=state.reload_dirs if not args.no_reload else None,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
3
src/govoplan_core/mail/__init__.py
Normal file
3
src/govoplan_core/mail/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from govoplan_core.mail.config import ImapConfig, SmtpConfig, TransportSecurity
|
||||
|
||||
__all__ = ["ImapConfig", "SmtpConfig", "TransportSecurity"]
|
||||
81
src/govoplan_core/mail/config.py
Normal file
81
src/govoplan_core/mail/config.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
|
||||
class TransportSecurity(StrEnum):
|
||||
PLAIN = "plain"
|
||||
TLS = "tls"
|
||||
STARTTLS = "starttls"
|
||||
|
||||
|
||||
class TransportCredentials(StrictModel):
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class SmtpServerConfig(StrictModel):
|
||||
host: str | None = None
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
security: TransportSecurity = TransportSecurity.STARTTLS
|
||||
timeout_seconds: int = Field(default=30, ge=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def apply_default_port(self) -> "SmtpServerConfig":
|
||||
if self.port is None:
|
||||
self.port = default_smtp_port(self.security)
|
||||
return self
|
||||
|
||||
|
||||
class ImapServerConfig(StrictModel):
|
||||
host: str | None = None
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
security: TransportSecurity = TransportSecurity.TLS
|
||||
sent_folder: str = "auto"
|
||||
timeout_seconds: int = Field(default=30, ge=1)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def discard_legacy_enabled(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict) and "enabled" in value:
|
||||
data = dict(value)
|
||||
data.pop("enabled", None)
|
||||
return data
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def apply_default_port(self) -> "ImapServerConfig":
|
||||
if self.port is None:
|
||||
self.port = default_imap_port(self.security)
|
||||
return self
|
||||
|
||||
|
||||
class SmtpConfig(SmtpServerConfig):
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class ImapConfig(ImapServerConfig):
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
def default_smtp_port(security: TransportSecurity | str | None) -> int:
|
||||
if security == TransportSecurity.TLS or security == "tls":
|
||||
return 465
|
||||
if security == TransportSecurity.PLAIN or security == "plain":
|
||||
return 25
|
||||
return 587
|
||||
|
||||
|
||||
def default_imap_port(security: TransportSecurity | str | None) -> int:
|
||||
if security == TransportSecurity.TLS or security == "tls":
|
||||
return 993
|
||||
return 143
|
||||
@@ -11,8 +11,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.governance import get_system_settings
|
||||
from govoplan_core.core.optional import reraise_unless_missing_package
|
||||
from govoplan_core.db.models import AuditLog, Group, SystemSettings, Tenant, User
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus, JobSendStatus
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
|
||||
@@ -57,11 +57,11 @@ FINAL_VERSION_STATES = {
|
||||
"archived",
|
||||
}
|
||||
FINAL_EML_SEND_STATUSES = {
|
||||
JobSendStatus.SMTP_ACCEPTED.value,
|
||||
JobSendStatus.SENT.value,
|
||||
JobSendStatus.FAILED_PERMANENT.value,
|
||||
JobSendStatus.CANCELLED.value,
|
||||
JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||
"smtp_accepted",
|
||||
"sent",
|
||||
"failed_permanent",
|
||||
"cancelled",
|
||||
"outcome_unknown",
|
||||
}
|
||||
AUDIT_DETAIL_REDACTION_KEYS = {
|
||||
"attachments",
|
||||
@@ -148,6 +148,22 @@ class PrivacyPolicyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _campaign_models():
|
||||
try:
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
|
||||
except ModuleNotFoundError as exc:
|
||||
reraise_unless_missing_package(exc, "govoplan_campaign")
|
||||
raise PrivacyPolicyError("Campaign module is not installed") from exc
|
||||
return Campaign, CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
|
||||
|
||||
|
||||
def _campaign_models_or_none():
|
||||
try:
|
||||
return _campaign_models()
|
||||
except PrivacyPolicyError:
|
||||
return None
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@@ -251,8 +267,9 @@ def effective_privacy_policy(
|
||||
campaign_id: str | None = None,
|
||||
) -> PrivacyRetentionPolicy:
|
||||
policy = privacy_policy_from_session(session)
|
||||
campaign: Campaign | None = None
|
||||
campaign: Any | None = None
|
||||
if campaign_id:
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
if campaign is None:
|
||||
raise PrivacyPolicyError("Campaign not found for privacy policy")
|
||||
@@ -290,6 +307,7 @@ def parent_privacy_policy(session: Session, *, tenant_id: str, scope_type: str,
|
||||
return policy
|
||||
if clean_scope != "campaign" or not scope_id:
|
||||
return policy
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise PrivacyPolicyError("Campaign not found for privacy policy")
|
||||
@@ -318,11 +336,13 @@ def _retention_policy_source_fields(patch: dict[str, Any], *, baseline: bool = F
|
||||
|
||||
|
||||
def _retention_policy_source_step(scope_type: str, label: str, scope_id: str | None, patch: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
|
||||
source_policy = PrivacyRetentionPolicy.model_validate(patch).model_dump(mode="json") if baseline else dict(patch)
|
||||
return {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"label": label,
|
||||
"applied_fields": _retention_policy_source_fields(patch, baseline=baseline),
|
||||
"policy": source_policy,
|
||||
}
|
||||
|
||||
|
||||
@@ -336,8 +356,9 @@ def effective_privacy_policy_sources(
|
||||
) -> list[dict[str, Any]]:
|
||||
system_settings = get_system_settings(session)
|
||||
sources = [_retention_policy_source_step("system", "System", None, _privacy_policy_patch_from_settings(system_settings.settings or {}), baseline=True)]
|
||||
campaign: Campaign | None = None
|
||||
campaign: Any | None = None
|
||||
if campaign_id:
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
if campaign is None:
|
||||
raise PrivacyPolicyError("Campaign not found for privacy policy")
|
||||
@@ -378,6 +399,7 @@ def parent_privacy_policy_sources(session: Session, *, tenant_id: str, scope_typ
|
||||
return sources
|
||||
if clean_scope != "campaign" or not scope_id:
|
||||
return sources
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise PrivacyPolicyError("Campaign not found for privacy policy")
|
||||
@@ -413,6 +435,7 @@ def get_privacy_policy_for_scope(session: Session, *, tenant_id: str, scope_type
|
||||
raise PrivacyPolicyError("Group privacy policy not found")
|
||||
return _privacy_policy_patch_from_settings(group.settings or {})
|
||||
if clean_scope == "campaign":
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise PrivacyPolicyError("Campaign privacy policy not found")
|
||||
@@ -460,6 +483,7 @@ def set_privacy_policy_for_scope(
|
||||
session.add(group)
|
||||
return patch
|
||||
if clean_scope == "campaign":
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise PrivacyPolicyError("Campaign privacy policy not found")
|
||||
@@ -542,6 +566,10 @@ def _campaign_policy_for_id(session: Session, campaign_id: str | None, cache: di
|
||||
|
||||
def _apply_raw_json_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]:
|
||||
result = {"eligible": 0, "redacted": 0, "skipped_not_final": 0, "already_redacted": 0}
|
||||
models = _campaign_models_or_none()
|
||||
if models is None:
|
||||
return result
|
||||
_, _, CampaignVersion, _, _ = models
|
||||
policy_cache: dict[str, PrivacyRetentionPolicy] = {}
|
||||
versions = (
|
||||
session.query(CampaignVersion)
|
||||
@@ -579,6 +607,10 @@ def _apply_raw_json_retention(session: Session, *, dry_run: bool, now: datetime)
|
||||
|
||||
def _apply_eml_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]:
|
||||
result = {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0}
|
||||
models = _campaign_models_or_none()
|
||||
if models is None:
|
||||
return result
|
||||
_, CampaignJob, _, JobImapStatus, JobQueueStatus = models
|
||||
policy_cache: dict[str, PrivacyRetentionPolicy] = {}
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
@@ -616,6 +648,10 @@ def _apply_eml_retention(session: Session, *, dry_run: bool, now: datetime) -> d
|
||||
|
||||
def _apply_report_detail_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]:
|
||||
result = {"eligible_versions": 0, "summaries_redacted": 0, "already_redacted": 0}
|
||||
models = _campaign_models_or_none()
|
||||
if models is None:
|
||||
return result
|
||||
_, _, CampaignVersion, _, _ = models
|
||||
policy_cache: dict[str, PrivacyRetentionPolicy] = {}
|
||||
versions = (
|
||||
session.query(CampaignVersion)
|
||||
@@ -708,9 +744,12 @@ def _parse_datetime(value: Any) -> datetime | None:
|
||||
|
||||
def _privacy_policy_for_audit_item(session: Session, item: AuditLog, campaign_cache: dict[str, PrivacyRetentionPolicy], tenant_cache: dict[str, PrivacyRetentionPolicy]) -> PrivacyRetentionPolicy:
|
||||
if item.object_type == "campaign" and item.object_id:
|
||||
campaign = session.get(Campaign, str(item.object_id))
|
||||
if campaign is not None:
|
||||
return _campaign_policy_for_id(session, campaign.id, campaign_cache)
|
||||
models = _campaign_models_or_none()
|
||||
if models is not None:
|
||||
Campaign, _, _, _, _ = models
|
||||
campaign = session.get(Campaign, str(item.object_id))
|
||||
if campaign is not None:
|
||||
return _campaign_policy_for_id(session, campaign.id, campaign_cache)
|
||||
if item.tenant_id:
|
||||
if item.tenant_id not in tenant_cache:
|
||||
tenant_cache[item.tenant_id] = effective_privacy_policy(session, tenant_id=item.tenant_id)
|
||||
|
||||
@@ -27,11 +27,12 @@ def create_app(config: GovoplanServerConfig | str | None = None):
|
||||
api_router.include_router(router)
|
||||
|
||||
module_context = ModuleContext(registry=registry, settings=server_config.settings, data=server_config.module_context_data)
|
||||
registry.configure_capability_context(module_context)
|
||||
for manifest in registry.manifests():
|
||||
if manifest.route_factory is not None:
|
||||
api_router.include_router(manifest.route_factory(module_context))
|
||||
|
||||
api_router.include_router(create_platform_router())
|
||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||
|
||||
for router in server_config.post_module_routers:
|
||||
api_router.include_router(router)
|
||||
|
||||
@@ -48,7 +48,16 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
||||
}
|
||||
|
||||
|
||||
def create_platform_router() -> APIRouter:
|
||||
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
|
||||
capabilities: list[str] = []
|
||||
if manifest_id == "mail":
|
||||
app_env = str(getattr(settings, "app_env", "dev")).lower()
|
||||
if app_env == "dev" and bool(getattr(settings, "dev_mailbox_api_enabled", False)) and registry.has_module("mail"):
|
||||
capabilities.append("mail.devMailbox")
|
||||
return capabilities
|
||||
|
||||
|
||||
def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
router = APIRouter(prefix="/platform", tags=["platform"])
|
||||
|
||||
@router.get("/modules")
|
||||
@@ -63,6 +72,7 @@ def create_platform_router() -> APIRouter:
|
||||
"dependencies": list(manifest.dependencies),
|
||||
"optional_dependencies": list(manifest.optional_dependencies),
|
||||
"enabled": True,
|
||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest.frontend),
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
|
||||
return [item for item in items if item]
|
||||
|
||||
|
||||
def available_module_manifests(manifest_factories: Sequence[ManifestFactory] = ()) -> dict[str, ModuleManifest]:
|
||||
manifests = {manifest.id: manifest for manifest in discover_module_manifests()}
|
||||
def available_module_manifests(manifest_factories: Sequence[ManifestFactory] = (), *, enabled_modules: Iterable[str] | None = None) -> dict[str, ModuleManifest]:
|
||||
manifests = {manifest.id: manifest for manifest in discover_module_manifests(enabled_modules=enabled_modules)}
|
||||
manifests.setdefault("access", get_access_manifest())
|
||||
for factory in manifest_factories:
|
||||
manifest = factory()
|
||||
@@ -32,7 +32,7 @@ def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_fa
|
||||
if "access" not in requested:
|
||||
requested.insert(0, "access")
|
||||
|
||||
available = available_module_manifests(manifest_factories)
|
||||
available = available_module_manifests(manifest_factories, enabled_modules=requested)
|
||||
registry = PlatformRegistry()
|
||||
for module_id in requested:
|
||||
manifest = available.get(module_id)
|
||||
|
||||
@@ -8,7 +8,7 @@ class Settings(BaseSettings):
|
||||
app_env: str = Field(default="dev", alias="APP_ENV")
|
||||
|
||||
database_url: str = Field(
|
||||
default="sqlite:///./multimailer-dev.db",
|
||||
default="sqlite:///./runtime/multimailer-dev.db",
|
||||
alias="DATABASE_URL",
|
||||
)
|
||||
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
|
||||
|
||||
@@ -215,19 +215,18 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"smtp": {
|
||||
"host": "mock.smtp",
|
||||
"port": 2525,
|
||||
"username": "sender@example.org",
|
||||
"password": "smtp-secret",
|
||||
"security": "starttls",
|
||||
},
|
||||
"imap": {
|
||||
"enabled": True,
|
||||
"host": "mock.imap",
|
||||
"port": 993,
|
||||
"username": "sender@example.org",
|
||||
"password": "imap-secret",
|
||||
"security": "tls",
|
||||
"sent_folder": "Sent",
|
||||
},
|
||||
"credentials": {
|
||||
"smtp": {"username": "sender@example.org", "password": "smtp-secret"},
|
||||
"imap": {"username": "sender@example.org", "password": "imap-secret"},
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(created_profile.status_code, 201, created_profile.text)
|
||||
@@ -264,25 +263,28 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"smtp": {
|
||||
"host": "mock.smtp",
|
||||
"port": 2525,
|
||||
"username": "sender@example.org",
|
||||
"password": "smtp-secret",
|
||||
"security": "starttls",
|
||||
},
|
||||
"imap": {
|
||||
"enabled": True,
|
||||
"host": "mock.imap",
|
||||
"port": 993,
|
||||
"username": "sender@example.org",
|
||||
"password": "imap-secret",
|
||||
"security": "tls",
|
||||
"sent_folder": "Sent",
|
||||
},
|
||||
"credentials": {
|
||||
"smtp": {"username": "sender@example.org", "password": "smtp-secret"},
|
||||
"imap": {"username": "sender@example.org", "password": "imap-secret"},
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(created_profile.status_code, 201, created_profile.text)
|
||||
profile_payload = created_profile.json()
|
||||
self.assertNotIn("password", profile_payload["smtp"])
|
||||
self.assertNotIn("username", profile_payload["smtp"])
|
||||
self.assertNotIn("password", profile_payload["imap"])
|
||||
self.assertNotIn("username", profile_payload["imap"])
|
||||
self.assertEqual(profile_payload["credentials"]["smtp"]["username"], "sender@example.org")
|
||||
self.assertEqual(profile_payload["credentials"]["imap"]["username"], "sender@example.org")
|
||||
profile_id = profile_payload["id"]
|
||||
|
||||
campaign_json = {
|
||||
@@ -325,8 +327,12 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
assert profile is not None
|
||||
self.assertNotEqual(profile.smtp_password_encrypted, "smtp-secret")
|
||||
self.assertNotEqual(profile.imap_password_encrypted, "imap-secret")
|
||||
self.assertEqual(profile.smtp_username, "sender@example.org")
|
||||
self.assertEqual(profile.imap_username, "sender@example.org")
|
||||
self.assertNotIn("password", profile.smtp_config)
|
||||
self.assertNotIn("username", profile.smtp_config)
|
||||
self.assertNotIn("password", profile.imap_config or {})
|
||||
self.assertNotIn("username", profile.imap_config or {})
|
||||
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
self.assertIsNotNone(version)
|
||||
@@ -346,19 +352,18 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"smtp": {
|
||||
"host": "mock.smtp",
|
||||
"port": 2525,
|
||||
"username": "profile-smtp@example.org",
|
||||
"password": "profile-smtp-secret",
|
||||
"security": "starttls",
|
||||
},
|
||||
"imap": {
|
||||
"enabled": True,
|
||||
"host": "mock.imap",
|
||||
"port": 993,
|
||||
"username": "profile-imap@example.org",
|
||||
"password": "profile-imap-secret",
|
||||
"security": "tls",
|
||||
"sent_folder": "Sent",
|
||||
},
|
||||
"credentials": {
|
||||
"smtp": {"username": "profile-smtp@example.org", "password": "profile-smtp-secret"},
|
||||
"imap": {"username": "profile-imap@example.org", "password": "profile-imap-secret"},
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(created_profile.status_code, 201, created_profile.text)
|
||||
@@ -373,8 +378,10 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"mail_profile_id": profile_id,
|
||||
"inherit_smtp_credentials": False,
|
||||
"inherit_imap_credentials": False,
|
||||
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
|
||||
"imap": {"username": "campaign-imap@example.org", "password": "campaign-imap-secret"},
|
||||
"credentials": {
|
||||
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
|
||||
"imap": {"username": "campaign-imap@example.org", "password": "campaign-imap-secret"},
|
||||
},
|
||||
},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||
@@ -2713,10 +2720,11 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"smtp": {
|
||||
"host": "mock.smtp",
|
||||
"port": 2525,
|
||||
"username": "profile-smtp@example.org",
|
||||
"password": "profile-smtp-secret",
|
||||
"security": "starttls",
|
||||
},
|
||||
"credentials": {
|
||||
"smtp": {"username": "profile-smtp@example.org", "password": "profile-smtp-secret"},
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(profile.status_code, 201, profile.text)
|
||||
@@ -2725,10 +2733,14 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
policy = self.client.put(
|
||||
"/api/v1/mail/policies/tenant",
|
||||
headers=headers,
|
||||
json={"policy": {"smtp_credentials": {"inherit": True, "allow_override": False}}},
|
||||
json={"policy": {
|
||||
"smtp_credentials": {"inherit": True},
|
||||
"allow_lower_level_limits": {"smtp_credentials.inherit": False},
|
||||
}},
|
||||
)
|
||||
self.assertEqual(policy.status_code, 200, policy.text)
|
||||
self.assertTrue(policy.json()["effective_policy"] is None)
|
||||
self.assertEqual(policy.json()["effective_policy"]["smtp_credentials"]["inherit"], True)
|
||||
self.assertEqual(policy.json()["effective_policy"]["allow_lower_level_limits"]["smtp_credentials.inherit"], False)
|
||||
|
||||
campaign_json = {
|
||||
"version": "1.0",
|
||||
@@ -2738,7 +2750,9 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"server": {
|
||||
"mail_profile_id": profile_id,
|
||||
"inherit_smtp_credentials": False,
|
||||
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
|
||||
"credentials": {
|
||||
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
|
||||
},
|
||||
},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||
|
||||
@@ -12,6 +12,7 @@ from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.migrations import (
|
||||
REVISION_AUTH_RBAC,
|
||||
REVISION_FILE_FOLDERS,
|
||||
REVISION_HIERARCHICAL_SETTINGS,
|
||||
alembic_config,
|
||||
migrate_database,
|
||||
)
|
||||
@@ -151,6 +152,85 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
self.assertEqual(system_owner_count, 1 if user_count else 0)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_repairs_current_schema_stamped_at_file_folders(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="msm-current-schema-marker-test-") as directory:
|
||||
database = Path(directory) / "current-schema-marker.db"
|
||||
url = f"sqlite:///{database}"
|
||||
command.upgrade(alembic_config(database_url=url), REVISION_HIERARCHICAL_SETTINGS)
|
||||
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO mail_server_profiles
|
||||
(id, tenant_id, scope_type, scope_id, name, slug, description, is_active,
|
||||
smtp_config, smtp_password_encrypted, imap_config, imap_password_encrypted,
|
||||
created_by_user_id, updated_by_user_id, created_at, updated_at)
|
||||
VALUES
|
||||
('profile-1', NULL, 'system', 'system', 'System Mail', 'system-mail', NULL, 1,
|
||||
:smtp_config, NULL, :imap_config, NULL,
|
||||
NULL, NULL, '2026-06-25 15:00:00', '2026-06-25 15:00:00')
|
||||
"""
|
||||
),
|
||||
{
|
||||
"smtp_config": '{"host":"smtp.example.test","port":587,"security":"starttls","username":"smtp-user","enabled":true}',
|
||||
"imap_config": '{"host":"imap.example.test","port":993,"security":"ssl","username":"imap-user","enabled":true}',
|
||||
},
|
||||
)
|
||||
connection.execute(
|
||||
text("UPDATE alembic_version SET version_num = :revision"),
|
||||
{"revision": REVISION_FILE_FOLDERS},
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
self.assertEqual(
|
||||
MigrationContext.configure(connection).get_current_revision(),
|
||||
REVISION_FILE_FOLDERS,
|
||||
)
|
||||
profile_columns = {
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns("mail_server_profiles")
|
||||
}
|
||||
self.assertNotIn("smtp_username", profile_columns)
|
||||
self.assertNotIn("imap_username", profile_columns)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
result = migrate_database(database_url=url)
|
||||
self.assertEqual(result.previous_revision, REVISION_FILE_FOLDERS)
|
||||
self.assertEqual(result.reconciled_revision, REVISION_HIERARCHICAL_SETTINGS)
|
||||
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
self.assertEqual(
|
||||
MigrationContext.configure(connection).get_current_revision(),
|
||||
result.current_revision,
|
||||
)
|
||||
profile_columns = {
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns("mail_server_profiles")
|
||||
}
|
||||
profile = connection.execute(text(
|
||||
"""
|
||||
SELECT smtp_username, smtp_config, imap_username, imap_config
|
||||
FROM mail_server_profiles
|
||||
WHERE id = 'profile-1'
|
||||
"""
|
||||
)).mappings().one()
|
||||
self.assertIn("smtp_username", profile_columns)
|
||||
self.assertIn("imap_username", profile_columns)
|
||||
self.assertEqual(profile["smtp_username"], "smtp-user")
|
||||
self.assertEqual(profile["imap_username"], "imap-user")
|
||||
self.assertNotIn("username", profile["smtp_config"])
|
||||
self.assertNotIn("enabled", profile["smtp_config"])
|
||||
self.assertNotIn("username", profile["imap_config"])
|
||||
self.assertNotIn("enabled", profile["imap_config"])
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_migrates_legacy_login_identity_and_bootstraps_system_owner(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="msm-account-migration-test-") as directory:
|
||||
database = Path(directory) / "accounts.db"
|
||||
|
||||
51
tests/test_devserver.py
Normal file
51
tests/test_devserver.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DevserverSmokeTests(unittest.TestCase):
|
||||
def test_smoke_mode_bootstraps_missing_local_sqlite_database(self) -> None:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
src_root = repo_root / "src"
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-devserver-smoke-") as directory:
|
||||
runtime_root = Path(directory)
|
||||
env = os.environ.copy()
|
||||
for key in (
|
||||
"DATABASE_URL",
|
||||
"DEV_BOOTSTRAP_ENABLED",
|
||||
"GOVOPLAN_SERVER_CONFIG",
|
||||
"FILE_STORAGE_LOCAL_ROOT",
|
||||
"MOCK_MAILBOX_DIR",
|
||||
):
|
||||
env.pop(key, None)
|
||||
env["APP_ENV"] = "dev"
|
||||
env["CELERY_ENABLED"] = "false"
|
||||
env["ENABLED_MODULES"] = "access"
|
||||
env["PYTHONPATH"] = str(src_root) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "govoplan_core.devserver", "--smoke", "--no-reload"],
|
||||
cwd=runtime_root,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
|
||||
db_path = runtime_root / "runtime" / "multimailer-dev.db"
|
||||
self.assertTrue(db_path.exists(), result.stdout)
|
||||
self.assertGreater(db_path.stat().st_size, 0, result.stdout)
|
||||
self.assertIn(f"Runtime root: {runtime_root}", result.stdout)
|
||||
self.assertIn(f"Database: sqlite:///{db_path}", result.stdout)
|
||||
self.assertIn("Dev bootstrap for missing SQLite DB: enabled", result.stdout)
|
||||
self.assertIn("Smoke check: OK", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
243
tests/test_module_system.py
Normal file
243
tests/test_module_system.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Keep the default app import side effect from bootstrapping a development DB.
|
||||
_TEST_ROOT = Path(tempfile.mkdtemp(prefix="govoplan-module-tests-"))
|
||||
os.environ.setdefault("APP_ENV", "test")
|
||||
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_ROOT / 'module-test.db'}")
|
||||
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false")
|
||||
os.environ.setdefault("CELERY_ENABLED", "false")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.core.migrations import migration_metadata_plan
|
||||
from govoplan_core.server.app import create_app
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
from govoplan_files.backend.storage.backends import LocalFilesystemStorageBackend, StorageBackendError
|
||||
|
||||
|
||||
def _settings(root: Path) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
app_env="test",
|
||||
database_url=f"sqlite:///{root / 'test.db'}",
|
||||
dev_mailbox_api_enabled=False,
|
||||
file_storage_backend="local",
|
||||
file_storage_local_root=str(root / "files"),
|
||||
file_storage_local_fallback_roots="",
|
||||
file_storage_s3_endpoint_url=None,
|
||||
file_storage_s3_region=None,
|
||||
file_storage_s3_bucket=None,
|
||||
file_storage_s3_access_key_id=None,
|
||||
file_storage_s3_secret_access_key=None,
|
||||
s3_endpoint_url=None,
|
||||
s3_region=None,
|
||||
s3_bucket=None,
|
||||
s3_access_key_id=None,
|
||||
s3_secret_access_key=None,
|
||||
file_upload_max_bytes=1024 * 1024,
|
||||
file_upload_zip_max_bytes=10 * 1024 * 1024,
|
||||
celery_enabled=False,
|
||||
redis_url="redis://127.0.0.1:6379/15",
|
||||
mock_mailbox_dir=str(root / "mock-mailbox"),
|
||||
)
|
||||
|
||||
|
||||
class ModuleSystemTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
shutil.rmtree(_TEST_ROOT, ignore_errors=True)
|
||||
|
||||
def _app_for_modules(self, modules: tuple[str, ...]):
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-app-", dir=_TEST_ROOT))
|
||||
settings = _settings(root)
|
||||
config = GovoplanServerConfig(
|
||||
title="GovOPlaN Module Test",
|
||||
version="test",
|
||||
settings=settings,
|
||||
enabled_modules=modules,
|
||||
base_routers=(),
|
||||
post_module_routers=(),
|
||||
extra_routers=(),
|
||||
lifespan=None,
|
||||
app_configurators=(),
|
||||
)
|
||||
return create_app(config), settings
|
||||
|
||||
def test_discovers_installed_core_and_product_module_manifests(self) -> None:
|
||||
manifests = available_module_manifests()
|
||||
self.assertTrue({"access", "files", "mail", "campaigns"}.issubset(manifests))
|
||||
self.assertEqual(manifests["campaigns"].dependencies, ("access",))
|
||||
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail"))
|
||||
|
||||
def test_enabled_module_permutations_register_expected_routes(self) -> None:
|
||||
cases = (
|
||||
("core_only", (), {"access"}, set()),
|
||||
("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}),
|
||||
("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}),
|
||||
("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}),
|
||||
("campaign_without_files", ("campaigns", "mail"), {"access", "campaigns", "mail"}, {"/api/v1/campaigns", "/api/v1/mail"}),
|
||||
("campaign_with_files_but_no_mail", ("campaigns", "files"), {"access", "campaigns", "files"}, {"/api/v1/campaigns", "/api/v1/files"}),
|
||||
("full_product", ("campaigns", "files", "mail"), {"access", "campaigns", "files", "mail"}, {"/api/v1/campaigns", "/api/v1/files", "/api/v1/mail"}),
|
||||
)
|
||||
for name, enabled_modules, expected_modules, expected_prefixes in cases:
|
||||
with self.subTest(name=name):
|
||||
app, _settings_obj = self._app_for_modules(enabled_modules)
|
||||
route_paths = {getattr(route, "path", "") for route in app.routes}
|
||||
self.assertIn("/api/v1/platform/modules", route_paths)
|
||||
for prefix in ("/api/v1/campaigns", "/api/v1/files", "/api/v1/mail"):
|
||||
has_prefix = any(path == prefix or path.startswith(f"{prefix}/") for path in route_paths)
|
||||
self.assertEqual(prefix in expected_prefixes, has_prefix, f"{name}: {prefix}")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/modules")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload_modules = {item["id"] for item in response.json()["modules"]}
|
||||
self.assertEqual(expected_modules, payload_modules)
|
||||
|
||||
|
||||
def _run_physical_absence_probe(self, *, enabled_modules: tuple[str, ...], blocked_modules: tuple[str, ...]) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-absence-probe-", dir=_TEST_ROOT))
|
||||
enabled = ",".join(("access", *enabled_modules)) if enabled_modules else "access"
|
||||
script = f"""
|
||||
import importlib.abc
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ["APP_ENV"] = "test"
|
||||
os.environ["DATABASE_URL"] = {f"sqlite:///{root / 'probe.db'}"!r}
|
||||
os.environ["DEV_BOOTSTRAP_ENABLED"] = "false"
|
||||
os.environ["CELERY_ENABLED"] = "false"
|
||||
os.environ["ENABLED_MODULES"] = {enabled!r}
|
||||
|
||||
BLOCKED = {blocked_modules!r}
|
||||
|
||||
class Blocker(importlib.abc.MetaPathFinder):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
for prefix in BLOCKED:
|
||||
if fullname == prefix or fullname.startswith(prefix + "."):
|
||||
raise ModuleNotFoundError(f"No module named {{prefix!r}}", name=prefix)
|
||||
return None
|
||||
|
||||
sys.meta_path.insert(0, Blocker())
|
||||
|
||||
from govoplan_core.server.app import create_app
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.server.registry import build_platform_registry
|
||||
|
||||
registry = build_platform_registry({enabled_modules!r})
|
||||
config = GovoplanServerConfig(
|
||||
title="absence probe",
|
||||
version="test",
|
||||
enabled_modules={enabled_modules!r},
|
||||
base_routers=(),
|
||||
post_module_routers=(),
|
||||
extra_routers=(),
|
||||
lifespan=None,
|
||||
app_configurators=(),
|
||||
)
|
||||
app = create_app(config)
|
||||
route_paths = [getattr(route, "path", "") for route in app.routes]
|
||||
print(json.dumps({{"modules": [item.id for item in registry.manifests()], "routes": route_paths}}, sort_keys=True))
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=str(Path(__file__).resolve().parents[1]),
|
||||
env=os.environ.copy(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
self.fail(result.stderr or result.stdout)
|
||||
|
||||
def test_campaign_optional_integrations_follow_enabled_modules(self) -> None:
|
||||
cases = (
|
||||
(("campaigns",), False, False),
|
||||
(("campaigns", "mail"), False, True),
|
||||
(("campaigns", "files"), True, False),
|
||||
(("campaigns", "files", "mail"), True, True),
|
||||
)
|
||||
for enabled_modules, files_enabled, mail_enabled in cases:
|
||||
with self.subTest(enabled_modules=enabled_modules):
|
||||
registry = build_platform_registry(enabled_modules)
|
||||
self.assertTrue(registry.has_module("campaigns"))
|
||||
self.assertEqual(files_enabled, registry.integration_enabled("campaigns", "files"))
|
||||
self.assertEqual(mail_enabled, registry.integration_enabled("campaigns", "mail"))
|
||||
|
||||
|
||||
def test_module_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None:
|
||||
cases = (
|
||||
((), ("govoplan_files", "govoplan_mail", "govoplan_campaign")),
|
||||
(("files",), ("govoplan_mail", "govoplan_campaign")),
|
||||
(("mail",), ("govoplan_files", "govoplan_campaign")),
|
||||
(("campaigns",), ("govoplan_files", "govoplan_mail")),
|
||||
(("campaigns", "files"), ("govoplan_mail",)),
|
||||
(("campaigns", "mail"), ("govoplan_files",)),
|
||||
(("campaigns", "files", "mail"), ()),
|
||||
)
|
||||
for enabled_modules, blocked_modules in cases:
|
||||
with self.subTest(enabled_modules=enabled_modules, blocked_modules=blocked_modules):
|
||||
self._run_physical_absence_probe(enabled_modules=enabled_modules, blocked_modules=blocked_modules)
|
||||
|
||||
def test_module_route_factories_receive_runtime_settings(self) -> None:
|
||||
app, settings = self._app_for_modules(("files", "mail"))
|
||||
self.assertIsNotNone(app)
|
||||
|
||||
from govoplan_files.backend.runtime import get_settings as get_files_settings
|
||||
from govoplan_mail.backend.runtime import get_settings as get_mail_settings
|
||||
|
||||
self.assertIs(settings, get_files_settings())
|
||||
self.assertIs(settings, get_mail_settings())
|
||||
|
||||
def test_module_migrations_are_registered_only_for_enabled_modules(self) -> None:
|
||||
core_plan = migration_metadata_plan(build_platform_registry(()))
|
||||
self.assertEqual((), core_plan.script_locations)
|
||||
self.assertEqual(1, len(core_plan.metadata))
|
||||
|
||||
files_plan = migration_metadata_plan(build_platform_registry(("files",)))
|
||||
self.assertEqual(1, len(files_plan.script_locations))
|
||||
self.assertTrue(files_plan.script_locations[0].endswith("govoplan_files/backend/migrations/versions"))
|
||||
|
||||
full_plan = migration_metadata_plan(build_platform_registry(("campaigns", "files", "mail")))
|
||||
locations = "\n".join(full_plan.script_locations)
|
||||
self.assertIn("govoplan_campaign/backend/migrations/versions", locations)
|
||||
self.assertIn("govoplan_files/backend/migrations/versions", locations)
|
||||
self.assertIn("govoplan_mail/backend/migrations/versions", locations)
|
||||
|
||||
def test_local_storage_backend_reads_from_fallback_roots_without_copying(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-storage-fallback-") as directory:
|
||||
root = Path(directory) / "primary"
|
||||
fallback = Path(directory) / "fallback"
|
||||
key = "tenant-a/files/demo.txt"
|
||||
(fallback / key).parent.mkdir(parents=True)
|
||||
(fallback / key).write_bytes(b"fallback-data")
|
||||
|
||||
backend = LocalFilesystemStorageBackend(root=root, fallback_roots=(fallback,))
|
||||
|
||||
self.assertTrue(backend.exists(key))
|
||||
self.assertEqual(b"fallback-data", backend.get_bytes(key))
|
||||
self.assertEqual([b"fallback", b"-data"], list(backend.iter_bytes(key, chunk_size=8)))
|
||||
self.assertFalse((root / key).exists())
|
||||
|
||||
backend.delete(key)
|
||||
self.assertTrue((fallback / key).exists())
|
||||
self.assertEqual(b"fallback-data", backend.get_bytes(key))
|
||||
|
||||
with self.assertRaises(StorageBackendError):
|
||||
backend.get_bytes("../escape.txt")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,7 +19,10 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
|
||||
37
webui/scripts/test-module-permutations.mjs
Normal file
37
webui/scripts/test-module-permutations.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const packageByModule = {
|
||||
campaigns: "@govoplan/campaign-webui",
|
||||
files: "@govoplan/files-webui",
|
||||
mail: "@govoplan/mail-webui"
|
||||
};
|
||||
|
||||
const cases = [
|
||||
{ name: "core-only", modules: [] },
|
||||
{ name: "files-only", modules: ["files"] },
|
||||
{ name: "mail-only", modules: ["mail"] },
|
||||
{ name: "campaign-only", modules: ["campaigns"] },
|
||||
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
|
||||
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
|
||||
{ name: "full-product", modules: ["campaigns", "files", "mail"] }
|
||||
];
|
||||
|
||||
const npmExec = process.env.npm_execpath;
|
||||
const command = npmExec ? process.execPath : (process.platform === "win32" ? "npm.cmd" : "npm");
|
||||
const baseArgs = npmExec ? [npmExec, "run", "build"] : ["run", "build"];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const packages = testCase.modules.map((moduleId) => packageByModule[moduleId]).join(",");
|
||||
console.log(`\n== WebUI module permutation: ${testCase.name} ==`);
|
||||
const result = spawnSync(command, baseArgs, {
|
||||
cwd: new URL("..", import.meta.url),
|
||||
env: {
|
||||
...process.env,
|
||||
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages
|
||||
},
|
||||
stdio: "inherit"
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,16 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { fetchMe } from "./api/auth";
|
||||
import { fetchPlatformModules } from "./api/platform";
|
||||
import { loadApiSettings, saveApiSettings } from "./api/client";
|
||||
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo } from "./types";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
import LoginModal from "./features/auth/LoginModal";
|
||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||
import { adminReadScopes } from "./utils/permissions";
|
||||
import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
@@ -19,6 +22,7 @@ export default function App() {
|
||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||
const [checkingSession, setCheckingSession] = useState(true);
|
||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||
const [reloginMessage, setReloginMessage] = useState("");
|
||||
|
||||
const webModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||
@@ -38,22 +42,39 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function handlePublicLogin(response: LoginResponse) {
|
||||
function authFromLoginResponse(response: LoginResponse): AuthInfo {
|
||||
const active = response.active_tenant ?? response.tenant;
|
||||
updateAuth(
|
||||
{
|
||||
user: response.user,
|
||||
tenant: active,
|
||||
active_tenant: active,
|
||||
tenants: response.tenants ?? [active],
|
||||
scopes: response.scopes,
|
||||
roles: response.roles,
|
||||
groups: response.groups
|
||||
},
|
||||
""
|
||||
);
|
||||
return {
|
||||
user: response.user,
|
||||
tenant: active,
|
||||
active_tenant: active,
|
||||
tenants: response.tenants ?? [active],
|
||||
scopes: response.scopes,
|
||||
roles: response.roles,
|
||||
groups: response.groups
|
||||
};
|
||||
}
|
||||
|
||||
function handlePublicLogin(response: LoginResponse) {
|
||||
updateAuth(authFromLoginResponse(response), "");
|
||||
}
|
||||
|
||||
function handleRelogin(response: LoginResponse) {
|
||||
updateAuth(authFromLoginResponse(response), "");
|
||||
setReloginMessage("");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleAuthRequired(event: Event) {
|
||||
if (!auth) return;
|
||||
const detail = (event as CustomEvent<AuthRequiredEventDetail>).detail;
|
||||
setReloginMessage(detail?.message || "Your session has expired. Sign in again to continue.");
|
||||
}
|
||||
|
||||
window.addEventListener(AUTH_REQUIRED_EVENT, handleAuthRequired);
|
||||
return () => window.removeEventListener(AUTH_REQUIRED_EVENT, handleAuthRequired);
|
||||
}, [auth]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCheckingSession(true);
|
||||
@@ -117,7 +138,9 @@ export default function App() {
|
||||
|
||||
if (checkingSession) {
|
||||
return (
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||
<div className="public-landing">
|
||||
<section className="public-card">
|
||||
<div className="public-kicker">GovOPlaN</div>
|
||||
@@ -125,26 +148,34 @@ export default function App() {
|
||||
<p>Please wait while the local session is verified.</p>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
return (
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||
<PublicLandingPage settings={settings} onLogin={handlePublicLogin} />
|
||||
</AppShell>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||
<PublicLandingPage settings={settings} onLogin={handlePublicLogin} />
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultRoute = firstAccessibleRoute(auth, webModules);
|
||||
|
||||
return (
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems}>
|
||||
<Suspense fallback={<div className="content-pad"><p className="muted">Loading module...</p></div>}>
|
||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/dashboard" element={<DashboardPage settings={settings} />} />
|
||||
{moduleRoutes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
@@ -165,6 +196,17 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
{reloginMessage && (
|
||||
<LoginModal
|
||||
settings={settings}
|
||||
title="Session expired"
|
||||
message={reloginMessage}
|
||||
onClose={() => setReloginMessage("")}
|
||||
onLogin={handleRelogin}
|
||||
/>
|
||||
)}
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export type PolicySourceStep = {
|
||||
scope_id?: string | null;
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyScopeResponse = {
|
||||
|
||||
@@ -6,6 +6,14 @@ const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
|
||||
const RECENT_SAFE_REQUEST_TTL_MS = 750;
|
||||
const MAX_RECENT_SAFE_REQUESTS = 100;
|
||||
|
||||
export const AUTH_REQUIRED_EVENT = "govoplan:auth-required";
|
||||
|
||||
export type AuthRequiredEventDetail = {
|
||||
path: string;
|
||||
status: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type RecentSafeRequest = {
|
||||
value: unknown;
|
||||
expiresAt: number;
|
||||
@@ -169,6 +177,25 @@ export function authHeaders(settings: ApiSettings): Headers {
|
||||
return headers;
|
||||
}
|
||||
|
||||
function shouldNotifyAuthRequired(path: string): boolean {
|
||||
const normalized = path.split("?", 1)[0].replace(/\/+$/, "");
|
||||
return !normalized.endsWith("/auth/login");
|
||||
}
|
||||
|
||||
function notifyAuthRequired(path: string): void {
|
||||
if (typeof window === "undefined" || !shouldNotifyAuthRequired(path)) return;
|
||||
const detail: AuthRequiredEventDetail = {
|
||||
path,
|
||||
status: 401,
|
||||
message: "Your session has expired. Sign in again to continue."
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent<AuthRequiredEventDetail>(AUTH_REQUIRED_EVENT, { detail }));
|
||||
}
|
||||
|
||||
function authExpiredError(statusText: string): ApiError {
|
||||
return new ApiError(401, statusText || "Unauthorized", "Your session has expired. Sign in again to continue.");
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers || {});
|
||||
const method = (init?.method || "GET").toUpperCase();
|
||||
@@ -200,6 +227,10 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
|
||||
notifyAuthRequired(path);
|
||||
throw authExpiredError(response.statusText);
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
}
|
||||
|
||||
@@ -252,6 +283,10 @@ export async function apiDownload(settings: ApiSettings, path: string, filename:
|
||||
const response = await fetch(apiUrl(settings, path), { headers: authHeaders(settings), credentials: "include" });
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
|
||||
notifyAuthRequired(path);
|
||||
throw authExpiredError(response.statusText);
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
|
||||
@@ -7,7 +7,4 @@ import "./styles/components.css";
|
||||
import "./styles/dialogs.css";
|
||||
import "./styles/retention-policies.css";
|
||||
import "./styles/auth-gate.css";
|
||||
import "@govoplan/campaign-webui/styles/campaign-workspace.css";
|
||||
import "@govoplan/files-webui/styles/file-manager.css";
|
||||
import "@govoplan/mail-webui/styles/mail-profiles.css";
|
||||
export { default, default as GovoplanApp } from "./App";
|
||||
|
||||
@@ -1,19 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
type CardProps = {
|
||||
title?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
collapsible?: boolean;
|
||||
collapseKey?: string;
|
||||
persistCollapse?: boolean;
|
||||
};
|
||||
|
||||
export default function Card({ title, children, actions, collapsible = false }: CardProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
function resolveCollapseStorageKey(collapsible: boolean, persistCollapse: boolean, collapseKey: string | undefined, title: ReactNode): string | null {
|
||||
if (!collapsible || !persistCollapse) return null;
|
||||
if (collapseKey) return `govoplan.card.collapsed:${collapseKey}`;
|
||||
if (typeof window === "undefined" || typeof title !== "string") return null;
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle) return null;
|
||||
return `govoplan.card.collapsed:${window.location.pathname}:${normalizedTitle}`;
|
||||
}
|
||||
|
||||
function readCollapseState(storageKey: string | null): boolean {
|
||||
if (!storageKey || typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapseState(storageKey: string | null, collapsed: boolean): void {
|
||||
if (!storageKey || typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, collapsed ? "1" : "0");
|
||||
} catch {
|
||||
// localStorage may be unavailable in private or restricted contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true }: CardProps) {
|
||||
const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title);
|
||||
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
|
||||
const collapsed = collapseState.storageKey === storageKey ? collapseState.collapsed : readCollapseState(storageKey);
|
||||
const hasHeader = Boolean(title || actions || collapsible);
|
||||
const body = <div className="card-body">{children}</div>;
|
||||
const shouldRenderBody = !collapsible || !collapsed;
|
||||
|
||||
useEffect(() => {
|
||||
setCollapseState({ storageKey, collapsed: readCollapseState(storageKey) });
|
||||
}, [storageKey]);
|
||||
|
||||
function toggleCollapsed() {
|
||||
const nextCollapsed = !collapsed;
|
||||
writeCollapseState(storageKey, nextCollapsed);
|
||||
setCollapseState({ storageKey, collapsed: nextCollapsed });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}>
|
||||
{hasHeader && (
|
||||
@@ -29,7 +70,7 @@ export default function Card({ title, children, actions, collapsible = false }:
|
||||
aria-label={collapsed ? "Show content" : "Show header only"}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Show content" : "Show header only"}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,7 @@ type DismissibleAlertProps = {
|
||||
compact?: boolean;
|
||||
floating?: boolean;
|
||||
resetKey?: string | number;
|
||||
dismissStorageKey?: string;
|
||||
};
|
||||
|
||||
let floatingAlertRoot: HTMLElement | null = null;
|
||||
@@ -34,6 +35,29 @@ function getFloatingAlertRoot(): HTMLElement | null {
|
||||
return floatingAlertRoot;
|
||||
}
|
||||
|
||||
function resolveDismissStorageKey(dismissStorageKey: string | undefined, resetKey: string | number | undefined): string | null {
|
||||
if (!dismissStorageKey) return null;
|
||||
return `govoplan.alert.dismissed:${dismissStorageKey}:${resetKey ?? "default"}`;
|
||||
}
|
||||
|
||||
function readDismissed(storageKey: string | null): boolean {
|
||||
if (!storageKey || typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeDismissed(storageKey: string | null): void {
|
||||
if (!storageKey || typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, "1");
|
||||
} catch {
|
||||
// localStorage may be unavailable in private or restricted contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export default function DismissibleAlert({
|
||||
tone = "info",
|
||||
children,
|
||||
@@ -41,16 +65,24 @@ export default function DismissibleAlert({
|
||||
className = "",
|
||||
compact = false,
|
||||
floating = false,
|
||||
resetKey
|
||||
resetKey,
|
||||
dismissStorageKey
|
||||
}: DismissibleAlertProps) {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const storageKey = resolveDismissStorageKey(dismissStorageKey, resetKey);
|
||||
const [alertState, setAlertState] = useState(() => ({ storageKey, visible: !readDismissed(storageKey) }));
|
||||
const visible = alertState.storageKey === storageKey ? alertState.visible : !readDismissed(storageKey);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(true);
|
||||
}, [resetKey, children]);
|
||||
setAlertState({ storageKey, visible: !readDismissed(storageKey) });
|
||||
}, [storageKey, resetKey, children]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
function dismiss() {
|
||||
writeDismissed(storageKey);
|
||||
setAlertState({ storageKey, visible: false });
|
||||
}
|
||||
|
||||
const role = tone === "danger" || tone === "warning" ? "alert" : "status";
|
||||
const alert = (
|
||||
<div
|
||||
@@ -60,7 +92,7 @@ export default function DismissibleAlert({
|
||||
>
|
||||
<div className="alert-message">{children}</div>
|
||||
{dismissible && (
|
||||
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={() => setVisible(false)}>
|
||||
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={dismiss}>
|
||||
<X size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Archive, LockKeyhole, Paperclip } from "lucide-react";
|
||||
|
||||
export type MessageDisplayField = {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
value?: ReactNode | null;
|
||||
};
|
||||
|
||||
export type MessageDisplayAttachment = {
|
||||
id?: string | null;
|
||||
filename?: string | null;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
contentType?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
detail?: string | null;
|
||||
archiveGroup?: string | null;
|
||||
archiveLabel?: string | null;
|
||||
protected?: boolean | null;
|
||||
protectionNote?: string | null;
|
||||
};
|
||||
|
||||
type MessageDisplayBodyMode = "text" | "html";
|
||||
|
||||
type MessageDisplayPanelProps = {
|
||||
title?: string | null;
|
||||
fields?: MessageDisplayField[];
|
||||
bodyText?: string | null;
|
||||
bodyHtml?: string | null;
|
||||
bodyPreview?: string | null;
|
||||
preferredBodyMode?: MessageDisplayBodyMode;
|
||||
deriveTextFromHtml?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
attachments?: MessageDisplayAttachment[];
|
||||
emptyText?: string;
|
||||
@@ -29,57 +39,210 @@ export default function MessageDisplayPanel({
|
||||
bodyText,
|
||||
bodyHtml,
|
||||
bodyPreview,
|
||||
preferredBodyMode,
|
||||
deriveTextFromHtml = true,
|
||||
headers = {},
|
||||
attachments = [],
|
||||
emptyText = "Select an item to inspect its content."
|
||||
}: MessageDisplayPanelProps) {
|
||||
if (!title && fields.length === 0 && !bodyText && !bodyHtml && !bodyPreview && attachments.length === 0 && Object.keys(headers).length === 0) {
|
||||
const visibleFields = fields.filter((field) => hasRenderableValue(field.value));
|
||||
const headerEntries = Object.entries(headers);
|
||||
const hasHtml = Boolean(bodyHtml?.trim());
|
||||
const explicitTextBody = bodyText?.trim() || bodyPreview?.trim() || "";
|
||||
const textBody = explicitTextBody || (deriveTextFromHtml && hasHtml ? stripHtml(bodyHtml || "") : "");
|
||||
const hasText = Boolean(textBody.trim());
|
||||
const defaultBodyMode = preferredBodyMode === "html" && hasHtml ? "html" : preferredBodyMode === "text" && hasText ? "text" : hasHtml ? "html" : "text";
|
||||
const [bodyMode, setBodyMode] = useState<MessageDisplayBodyMode>(defaultBodyMode);
|
||||
const groupedAttachments = useMemo(() => groupAttachments(attachments), [attachments]);
|
||||
|
||||
useEffect(() => {
|
||||
setBodyMode(defaultBodyMode);
|
||||
}, [defaultBodyMode, title, bodyText, bodyHtml, bodyPreview]);
|
||||
|
||||
if (!title && visibleFields.length === 0 && !hasText && !hasHtml && attachments.length === 0 && headerEntries.length === 0) {
|
||||
return <p className="muted">{emptyText}</p>;
|
||||
}
|
||||
|
||||
const body = bodyText || bodyPreview || (bodyHtml ? stripHtml(bodyHtml) : "");
|
||||
const activeBodyMode = bodyMode === "html" && hasHtml ? "html" : "text";
|
||||
const showBodySwitch = hasHtml && hasText;
|
||||
|
||||
return (
|
||||
<div className="message-display-panel">
|
||||
<div className="message-display-header">
|
||||
<h3>{title || "(no subject)"}</h3>
|
||||
{fields.filter((field) => field.value).map((field) => (
|
||||
<div key={field.label}>
|
||||
<span>{field.label}</span>
|
||||
<strong>{field.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
{visibleFields.length > 0 && (
|
||||
<dl className="message-display-fields">
|
||||
{visibleFields.map((field) => (
|
||||
<div key={field.label}>
|
||||
<dt>{field.label}</dt>
|
||||
<dd>{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<pre className="message-display-body">{body || "No readable body content."}</pre>
|
||||
<section className="message-display-body-section" aria-label="Message body">
|
||||
<div className="message-display-section-heading">
|
||||
<h4>Message</h4>
|
||||
{showBodySwitch && (
|
||||
<div className="message-display-body-switch" role="tablist" aria-label="Message body format">
|
||||
<button type="button" className={activeBodyMode === "html" ? "active" : ""} onClick={() => setBodyMode("html")}>HTML</button>
|
||||
<button type="button" className={activeBodyMode === "text" ? "active" : ""} onClick={() => setBodyMode("text")}>Text</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{activeBodyMode === "html" ? (
|
||||
<iframe className="message-display-html-frame" title="Rendered HTML message body" sandbox="" srcDoc={bodyHtml || "<p>No HTML body content.</p>"} />
|
||||
) : (
|
||||
<pre className="message-display-body">{textBody || "No readable body content."}</pre>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{attachments.length ? (
|
||||
<div className="message-display-attachments">
|
||||
<h4>Attachments</h4>
|
||||
{attachments.map((attachment, index) => (
|
||||
<div key={attachment.id || `${attachment.filename || "attachment"}-${index}`}>
|
||||
<Paperclip size={14} aria-hidden="true" />
|
||||
<span>{attachment.filename || "unnamed attachment"}</span>
|
||||
<small>{attachment.contentType} - {formatBytes(attachment.sizeBytes)}</small>
|
||||
</div>
|
||||
))}
|
||||
<div className="message-display-attachments-scroll">
|
||||
{groupedAttachments.direct.length > 0 && (
|
||||
<div className="message-display-attachment-list">
|
||||
{groupedAttachments.direct.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
||||
</div>
|
||||
)}
|
||||
{groupedAttachments.archives.map((archive) => (
|
||||
<section className="message-display-attachment-archive" key={archive.key}>
|
||||
<header>
|
||||
<div>
|
||||
<strong><Archive size={15} aria-hidden="true" /> {archive.label}</strong>
|
||||
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span>
|
||||
</div>
|
||||
{archive.protected && <small><LockKeyhole size={13} aria-hidden="true" /> Password protected</small>}
|
||||
</header>
|
||||
{archive.protectionNote && <p className="muted small-note">{formatProtectionNote(archive.protectionNote)}</p>}
|
||||
<div className="message-display-attachment-list">
|
||||
{archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<details className="message-display-headers">
|
||||
<summary>Headers</summary>
|
||||
<dl>
|
||||
{Object.entries(headers).map(([key, value]) => (
|
||||
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
{headerEntries.length > 0 && (
|
||||
<details className="message-display-headers">
|
||||
<summary>Headers</summary>
|
||||
<dl>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentRow({ attachment, index }: { attachment: MessageDisplayAttachment; index: number }) {
|
||||
const contentType = formatContentType(attachment.contentType);
|
||||
const size = formatBytes(attachment.sizeBytes);
|
||||
const hasMeta = Boolean(contentType || size);
|
||||
return (
|
||||
<div className="message-display-attachment-row">
|
||||
<Paperclip size={14} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{attachment.filename || `Attachment ${index + 1}`}</strong>
|
||||
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
|
||||
{hasMeta && (
|
||||
<small className="message-display-attachment-meta">
|
||||
{contentType && <span title={contentType.full}>{contentType.label}</span>}
|
||||
{size && <span>{size}</span>}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupAttachments(attachments: MessageDisplayAttachment[]) {
|
||||
const direct = attachments.filter((attachment) => !attachment.archiveGroup);
|
||||
const archiveMap = new Map<string, MessageDisplayAttachment[]>();
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.archiveGroup) continue;
|
||||
const current = archiveMap.get(attachment.archiveGroup) ?? [];
|
||||
current.push(attachment);
|
||||
archiveMap.set(attachment.archiveGroup, current);
|
||||
}
|
||||
return {
|
||||
direct,
|
||||
archives: [...archiveMap.entries()].map(([key, items]) => ({
|
||||
key,
|
||||
label: items[0]?.archiveLabel || key,
|
||||
protected: items.some((item) => item.protected),
|
||||
protectionNote: items.find((item) => item.protectionNote)?.protectionNote ?? null,
|
||||
items
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentKey(attachment: MessageDisplayAttachment, index: number): string {
|
||||
return String(attachment.id || `${attachment.archiveGroup || "direct"}:${attachment.filename || "attachment"}:${index}`);
|
||||
}
|
||||
|
||||
function hasRenderableValue(value: ReactNode | null | undefined): boolean {
|
||||
if (value === null || value === undefined || value === false) return false;
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatProtectionNote(value: string): string {
|
||||
return value
|
||||
.replace(/^Password-protected ZIP(?: using)?\s*/i, "")
|
||||
.replace(/^Password protected(?: using)?\s*/i, "")
|
||||
.replace(/^using\s+/i, "")
|
||||
.replace(/\.\s*Encryption:/i, " · Encryption:")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatContentType(value?: string | null): { label: string; full: string } | null {
|
||||
const full = value?.trim();
|
||||
if (!full) return null;
|
||||
const normalized = full.toLowerCase();
|
||||
const known: Record<string, string> = {
|
||||
"application/pdf": "PDF",
|
||||
"application/zip": "ZIP archive",
|
||||
"application/x-zip-compressed": "ZIP archive",
|
||||
"application/octet-stream": "Binary file",
|
||||
"text/plain": "Plain text",
|
||||
"text/html": "HTML",
|
||||
"text/csv": "CSV",
|
||||
"application/json": "JSON",
|
||||
"image/jpeg": "JPEG image",
|
||||
"image/png": "PNG image",
|
||||
"image/gif": "GIF image",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX spreadsheet",
|
||||
"application/vnd.ms-excel": "Excel spreadsheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX document",
|
||||
"application/msword": "Word document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "PPTX presentation",
|
||||
"application/vnd.ms-powerpoint": "PowerPoint presentation"
|
||||
};
|
||||
if (known[normalized]) return { label: known[normalized], full };
|
||||
const subtype = normalized.split("/")[1]?.split(";")[0] || normalized;
|
||||
const cleaned = subtype
|
||||
.replace(/^vnd\./, "")
|
||||
.replace(/^x-/, "")
|
||||
.replace(/openxmlformats-officedocument\./g, "")
|
||||
.replace(/[.+_-]+/g, " ")
|
||||
.trim();
|
||||
const label = titleCase(cleaned || normalized);
|
||||
return { label: label.length > 34 ? `${label.slice(0, 31)}...` : label, full };
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.replace(/\b[a-z]/g, (match) => match.toUpperCase());
|
||||
}
|
||||
|
||||
function formatBytes(value?: number | null): string {
|
||||
if (!value) return "-";
|
||||
if (!value) return "";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
|
||||
@@ -4,6 +4,7 @@ export type PolicySourcePathItem = string | {
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
appliedFields?: string[];
|
||||
policy?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type PolicySourcePathProps = {
|
||||
|
||||
180
webui/src/components/UnsavedChangesGuard.tsx
Normal file
180
webui/src/components/UnsavedChangesGuard.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Button from "./Button";
|
||||
import DismissibleAlert from "./DismissibleAlert";
|
||||
|
||||
export type UnsavedNavigationAction = () => void;
|
||||
|
||||
export type UnsavedChangesRegistration = {
|
||||
title?: string;
|
||||
message?: string;
|
||||
onSave: () => boolean | Promise<boolean>;
|
||||
onDiscard?: () => void;
|
||||
};
|
||||
|
||||
type UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: boolean;
|
||||
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
||||
requestNavigation: (action: UnsavedNavigationAction) => void;
|
||||
};
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
|
||||
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const [registration, setRegistration] = useState<UnsavedChangesRegistration | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<UnsavedNavigationAction | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const registrationRef = useRef<UnsavedChangesRegistration | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
registrationRef.current = registration;
|
||||
}, [registration]);
|
||||
|
||||
const hasUnsavedChanges = Boolean(registration);
|
||||
|
||||
const registerUnsavedChanges = useCallback((next: UnsavedChangesRegistration | null) => {
|
||||
setRegistration(next);
|
||||
return () => {
|
||||
setRegistration((current) => current === next ? null : current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const proceed = useCallback((action: UnsavedNavigationAction) => {
|
||||
setPendingAction(null);
|
||||
setSaveError("");
|
||||
action();
|
||||
}, []);
|
||||
|
||||
const requestNavigation = useCallback((action: UnsavedNavigationAction) => {
|
||||
const active = registrationRef.current;
|
||||
if (!active) {
|
||||
action();
|
||||
return;
|
||||
}
|
||||
setSaveError("");
|
||||
setPendingAction(() => action);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!registrationRef.current) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!registrationRef.current) return;
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
|
||||
|
||||
const target = event.target as Element | null;
|
||||
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||
if (!anchor) return;
|
||||
if (anchor.target && anchor.target !== "_self") return;
|
||||
if (anchor.hasAttribute("download")) return;
|
||||
if (anchor.getAttribute("href")?.startsWith("#")) return;
|
||||
|
||||
const destination = new URL(anchor.href, window.location.href);
|
||||
const current = new URL(window.location.href);
|
||||
if (destination.href === current.href) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
requestNavigation(() => {
|
||||
if (destination.origin === current.origin) {
|
||||
navigate(`${destination.pathname}${destination.search}${destination.hash}`);
|
||||
} else {
|
||||
window.location.assign(destination.href);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", onDocumentClick, true);
|
||||
return () => document.removeEventListener("click", onDocumentClick, true);
|
||||
}, [navigate, requestNavigation]);
|
||||
|
||||
async function handleSaveAndLeave() {
|
||||
const action = pendingAction;
|
||||
const active = registrationRef.current;
|
||||
if (!action || !active) return;
|
||||
|
||||
setSaving(true);
|
||||
setSaveError("");
|
||||
try {
|
||||
const ok = await active.onSave();
|
||||
if (!ok) {
|
||||
setSaveError("The changes could not be saved. Please review the page message and try again.");
|
||||
return;
|
||||
}
|
||||
proceed(action);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscardAndLeave() {
|
||||
const action = pendingAction;
|
||||
const active = registrationRef.current;
|
||||
if (!action) return;
|
||||
active?.onDiscard?.();
|
||||
proceed(action);
|
||||
}
|
||||
|
||||
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
||||
hasUnsavedChanges,
|
||||
registerUnsavedChanges,
|
||||
requestNavigation
|
||||
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
{children}
|
||||
{pendingAction && registration && (
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true">
|
||||
<div className="modal-panel unsaved-changes-dialog">
|
||||
<header className="modal-header">
|
||||
<h2>{registration.title ?? "Unsaved changes"}</h2>
|
||||
<button className="modal-close" onClick={() => setPendingAction(null)} disabled={saving}>x</button>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
|
||||
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
|
||||
</div>
|
||||
<footer className="modal-footer unsaved-changes-actions">
|
||||
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
|
||||
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "Saving..." : "Save and leave"}</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</UnsavedChangesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: false,
|
||||
registerUnsavedChanges: () => () => undefined,
|
||||
requestNavigation: (action) => action()
|
||||
};
|
||||
|
||||
export function useUnsavedChanges() {
|
||||
return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext;
|
||||
}
|
||||
|
||||
export function useRegisterUnsavedChanges(registration: UnsavedChangesRegistration | null) {
|
||||
const { registerUnsavedChanges } = useUnsavedChanges();
|
||||
|
||||
useEffect(() => {
|
||||
return registerUnsavedChanges(registration);
|
||||
}, [registerUnsavedChanges, registration]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import Button from "../Button";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
import FormField from "../FormField";
|
||||
@@ -7,17 +7,21 @@ import ToggleSwitch from "../ToggleSwitch";
|
||||
|
||||
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
||||
|
||||
export type MailServerCredentialSettings = {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type MailServerSmtpSettings = {
|
||||
host?: string | null;
|
||||
port?: string | number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
security?: MailServerSecurity | null;
|
||||
timeout_seconds?: string | number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type MailServerImapSettings = MailServerSmtpSettings & {
|
||||
enabled?: boolean;
|
||||
sent_folder?: string | null;
|
||||
};
|
||||
|
||||
@@ -48,20 +52,20 @@ export type MailServerSettingsPanelProps = {
|
||||
imap: MailServerImapSettings;
|
||||
onSmtpChange: (patch: Partial<MailServerSmtpSettings>) => void;
|
||||
onImapChange: (patch: Partial<MailServerImapSettings>) => void;
|
||||
onImapEnabledChange?: (enabled: boolean) => void;
|
||||
smtpCredentials?: MailServerCredentialSettings;
|
||||
imapCredentials?: MailServerCredentialSettings;
|
||||
onSmtpCredentialsChange?: (patch: Partial<MailServerCredentialSettings>) => void;
|
||||
onImapCredentialsChange?: (patch: Partial<MailServerCredentialSettings>) => void;
|
||||
smtpDisabled?: boolean;
|
||||
smtpCredentialDisabled?: boolean;
|
||||
smtpPasswordSaved?: boolean;
|
||||
smtpSavedPasswordPlaceholder?: string;
|
||||
smtpActionDisabled?: boolean;
|
||||
imapToggleDisabled?: boolean;
|
||||
imapServerDisabled?: boolean;
|
||||
imapCredentialDisabled?: boolean;
|
||||
imapPasswordSaved?: boolean;
|
||||
imapSavedPasswordPlaceholder?: string;
|
||||
imapActionDisabled?: boolean;
|
||||
smtpTitle?: ReactNode;
|
||||
imapTitle?: ReactNode;
|
||||
smtpTestLabel?: string;
|
||||
imapTestLabel?: string;
|
||||
folderLookupLabel?: string;
|
||||
@@ -91,29 +95,127 @@ export type MailServerSettingsPanelProps = {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
floatingResults?: boolean;
|
||||
initialSection?: MailServerSettingsSection;
|
||||
};
|
||||
|
||||
const securityOptions = ["plain", "tls", "starttls"];
|
||||
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
|
||||
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
|
||||
const securityOptions = mailServerSecurityOptions;
|
||||
type MailServerSettingsSection = "smtp" | "imap" | "advanced";
|
||||
|
||||
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
|
||||
if (security === "tls") return 465;
|
||||
if (security === "plain") return 25;
|
||||
return 587;
|
||||
}
|
||||
|
||||
export function defaultImapPort(security: MailServerSecurity | null | undefined): number {
|
||||
return security === "tls" ? 993 : 143;
|
||||
}
|
||||
|
||||
|
||||
export function mailTextOrNull(value: string | number | null | undefined, trim = true): string | null {
|
||||
const text = value === null || value === undefined ? "" : String(value);
|
||||
const normalized = trim ? text.trim() : text;
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
export function mailNumberOrNull(value: string | number | null | undefined): number | null {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return null;
|
||||
const parsed = Number(text);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function mailNumberOrDefault(value: string | number | null | undefined, fallback: number): number {
|
||||
return mailNumberOrNull(value) ?? fallback;
|
||||
}
|
||||
|
||||
export function normalizeMailServerSecurity<TSecurity extends string = MailServerSecurityOption>(
|
||||
value: string | null | undefined,
|
||||
options: { fallback: TSecurity; allowedSecurity?: readonly TSecurity[] },
|
||||
): TSecurity {
|
||||
const allowed = options.allowedSecurity ?? (mailServerSecurityOptions as unknown as readonly TSecurity[]);
|
||||
return allowed.includes(value as TSecurity) ? (value as TSecurity) : options.fallback;
|
||||
}
|
||||
|
||||
export function mailTransportCredentialsPayload(
|
||||
username: string | number | null | undefined,
|
||||
password: string | number | null | undefined,
|
||||
preserveBlankPassword: boolean,
|
||||
): { username?: string | null; password?: string | null } {
|
||||
const payload: { username?: string | null; password?: string | null } = { username: mailTextOrNull(username) };
|
||||
if (String(password ?? "") || !preserveBlankPassword) payload.password = mailTextOrNull(password, false);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function mailRecordText(record: Record<string, unknown>, key: string, fallback = ""): string {
|
||||
const value = record[key];
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function mailTransportCredentialsPayloadFromRecords(
|
||||
primary: Record<string, unknown>,
|
||||
legacy: Record<string, unknown>,
|
||||
preserveBlankPassword: boolean,
|
||||
): { username?: string | null; password?: string | null } {
|
||||
return mailTransportCredentialsPayload(
|
||||
mailRecordText(primary, "username", mailRecordText(legacy, "username")),
|
||||
mailRecordText(primary, "password", mailRecordText(legacy, "password")),
|
||||
preserveBlankPassword,
|
||||
);
|
||||
}
|
||||
|
||||
export function mailSmtpSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
|
||||
settings: MailServerSmtpSettings,
|
||||
options: { fallbackSecurity: TSecurity; allowedSecurity?: readonly TSecurity[]; fallbackTimeoutSeconds?: number },
|
||||
): { host: string | null; port: number | null; security: TSecurity; timeout_seconds: number } {
|
||||
return {
|
||||
host: mailTextOrNull(settings.host),
|
||||
port: mailNumberOrNull(settings.port),
|
||||
security: normalizeMailServerSecurity(settings.security ? String(settings.security) : null, { fallback: options.fallbackSecurity, allowedSecurity: options.allowedSecurity }),
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30),
|
||||
};
|
||||
}
|
||||
|
||||
export function mailImapSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
|
||||
settings: MailServerImapSettings,
|
||||
options: { fallbackSecurity: TSecurity; allowedSecurity?: readonly TSecurity[]; fallbackTimeoutSeconds?: number },
|
||||
): { host: string | null; port: number | null; security: TSecurity; sent_folder: string; timeout_seconds: number } {
|
||||
return {
|
||||
host: mailTextOrNull(settings.host),
|
||||
port: mailNumberOrNull(settings.port),
|
||||
security: normalizeMailServerSecurity(settings.security ? String(settings.security) : null, { fallback: options.fallbackSecurity, allowedSecurity: options.allowedSecurity }),
|
||||
sent_folder: mailTextOrNull(settings.sent_folder) || "auto",
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasMailImapSettings(values: Array<string | number | null | undefined>): boolean {
|
||||
return values.some((value) => String(value ?? "").trim() !== "");
|
||||
}
|
||||
|
||||
export default function MailServerSettingsPanel({
|
||||
smtp,
|
||||
imap,
|
||||
onSmtpChange,
|
||||
onImapChange,
|
||||
onImapEnabledChange,
|
||||
smtpCredentials,
|
||||
imapCredentials,
|
||||
onSmtpCredentialsChange,
|
||||
onImapCredentialsChange,
|
||||
smtpDisabled = false,
|
||||
smtpCredentialDisabled = smtpDisabled,
|
||||
smtpPasswordSaved = false,
|
||||
smtpSavedPasswordPlaceholder = "••••••••",
|
||||
smtpActionDisabled = smtpDisabled,
|
||||
imapToggleDisabled = false,
|
||||
imapServerDisabled = false,
|
||||
imapCredentialDisabled = imapServerDisabled,
|
||||
imapPasswordSaved = false,
|
||||
imapSavedPasswordPlaceholder = "••••••••",
|
||||
imapActionDisabled = imapServerDisabled,
|
||||
smtpTitle = "SMTP login",
|
||||
imapTitle = "IMAP sent-folder append",
|
||||
smtpTestLabel = "Test SMTP",
|
||||
imapTestLabel = "Test IMAP",
|
||||
folderLookupLabel = "Folders...",
|
||||
@@ -130,7 +232,8 @@ export default function MailServerSettingsPanel({
|
||||
append,
|
||||
disabled = false,
|
||||
className = "",
|
||||
floatingResults = false
|
||||
floatingResults = false,
|
||||
initialSection = "smtp"
|
||||
}: MailServerSettingsPanelProps) {
|
||||
const smtpFieldsDisabled = disabled || smtpDisabled;
|
||||
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
|
||||
@@ -138,92 +241,156 @@ export default function MailServerSettingsPanel({
|
||||
const imapFieldsDisabled = disabled || imapServerDisabled;
|
||||
const imapCredentialFieldsDisabled = disabled || imapCredentialDisabled;
|
||||
const imapActionsDisabled = disabled || imapActionDisabled;
|
||||
const smtpCredentialValues = smtpCredentials ?? { username: smtp.username, password: smtp.password };
|
||||
const imapCredentialValues = imapCredentials ?? { username: imap.username, password: imap.password };
|
||||
const smtpSecurity = stringValue(smtp.security, "starttls");
|
||||
const imapSecurity = stringValue(imap.security, "tls");
|
||||
const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity)));
|
||||
const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity)));
|
||||
const appendTargetFolder = append ? append.folder : stringValue(imap.sent_folder, "auto");
|
||||
const appendTargetDisabled = append ? disabled || append.folderDisabled : imapFieldsDisabled;
|
||||
const canLookupAppendFolders = Boolean(onLookupFolders) && !appendTargetDisabled;
|
||||
const appendTargetHelp = append
|
||||
? "Folder for sent-message copies. Leave as auto unless this campaign needs a different target."
|
||||
: "Folder used when this IMAP account is used for sent-message copies. Leave as auto to use the server default.";
|
||||
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(initialSection);
|
||||
const sections: { id: MailServerSettingsSection; label: string }[] = [
|
||||
{ id: "smtp", label: "SMTP" },
|
||||
{ id: "imap", label: "IMAP" },
|
||||
{ id: "advanced", label: "Advanced" }
|
||||
];
|
||||
|
||||
function patchSmtpSecurity(security: MailServerSecurity) {
|
||||
const port = portForSecurityChange("smtp", smtp.port, smtpSecurity, security);
|
||||
onSmtpChange(port === undefined ? { security } : { security, port });
|
||||
}
|
||||
|
||||
function patchImapSecurity(security: MailServerSecurity) {
|
||||
const port = portForSecurityChange("imap", imap.port, imapSecurity, security);
|
||||
onImapChange(port === undefined ? { security } : { security, port });
|
||||
}
|
||||
|
||||
function patchSmtpCredentials(patch: Partial<MailServerCredentialSettings>) {
|
||||
if (onSmtpCredentialsChange) onSmtpCredentialsChange(patch);
|
||||
else onSmtpChange(patch);
|
||||
}
|
||||
|
||||
function patchImapCredentials(patch: Partial<MailServerCredentialSettings>) {
|
||||
if (onImapCredentialsChange) onImapCredentialsChange(patch);
|
||||
else onImapChange(patch);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
||||
<div className="mail-server-settings-grid">
|
||||
<section className="mail-server-subsection">
|
||||
<div className="mail-server-section-heading split">
|
||||
<h3>{smtpTitle}</h3>
|
||||
{mockToggle && (
|
||||
<ToggleSwitch
|
||||
label={mockToggle.label ?? "Mock server settings"}
|
||||
checked={mockToggle.checked}
|
||||
disabled={disabled || mockToggle.disabled}
|
||||
onChange={mockToggle.onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(smtp.port)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Username"><input value={stringValue(smtp.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField
|
||||
value={stringValue(smtp.password)}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
saved={smtpPasswordSaved}
|
||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => onSmtpChange({ password })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Security"><SecuritySelect value={stringValue(smtp.security, "starttls")} disabled={smtpFieldsDisabled} onChange={(security) => onSmtpChange({ security })} /></FormField>
|
||||
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
</div>
|
||||
{onTestSmtp && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "Testing..." : smtpTestLabel}</Button>
|
||||
</div>
|
||||
)}
|
||||
<MailServerActionResult result={smtpTestResult} floating={floatingResults} />
|
||||
</section>
|
||||
<div className="mail-server-segmented-control" role="tablist" aria-label="Mail server settings sections">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
type="button"
|
||||
key={section.id}
|
||||
role="tab"
|
||||
aria-selected={activeSection === section.id}
|
||||
className={`mail-server-segmented-tab${activeSection === section.id ? " is-active" : ""}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mail-server-subsection">
|
||||
<div className="mail-server-section-heading split">
|
||||
<h3>{imapTitle}</h3>
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
{onImapEnabledChange && (
|
||||
<div className="mail-server-field-span mail-server-toggle-row">
|
||||
<ToggleSwitch label="Enable IMAP" checked={Boolean(imap.enabled)} disabled={disabled || imapToggleDisabled} onChange={onImapEnabledChange} />
|
||||
<div className="mail-server-settings-view">
|
||||
{activeSection === "smtp" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="SMTP settings">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<div className="mail-server-field-heading">Server</div>
|
||||
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Security"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">Credentials</div>
|
||||
<FormField label="Username"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField
|
||||
value={stringValue(smtpCredentialValues.password)}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
saved={smtpPasswordSaved}
|
||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchSmtpCredentials({ password })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
{onTestSmtp && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "Testing..." : smtpTestLabel}</Button>
|
||||
</div>
|
||||
)}
|
||||
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(imap.port)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Username"><input value={stringValue(imap.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField
|
||||
value={stringValue(imap.password)}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
saved={imapPasswordSaved}
|
||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => onImapChange({ password })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Security"><SecuritySelect value={stringValue(imap.security, "tls")} disabled={imapFieldsDisabled} onChange={(security) => onImapChange({ security })} /></FormField>
|
||||
<FormField label="Detected/saved sent folder"><input value={stringValue(imap.sent_folder, "auto")} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ sent_folder: event.target.value })} /></FormField>
|
||||
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
{append && (
|
||||
<>
|
||||
<MailServerActionResult result={smtpTestResult} floating={floatingResults} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeSection === "imap" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="IMAP settings">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<div className="mail-server-field-heading">Server</div>
|
||||
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Security"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">Credentials</div>
|
||||
<FormField label="Username"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField
|
||||
value={stringValue(imapCredentialValues.password)}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
saved={imapPasswordSaved}
|
||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchImapCredentials({ password })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
{onTestImap && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "Testing..." : imapTestLabel}</Button>
|
||||
</div>
|
||||
)}
|
||||
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeSection === "advanced" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="Advanced mail settings">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid mail-server-advanced-grid">
|
||||
{mockToggle && (
|
||||
<div className="mail-server-field-span mail-server-toggle-row">
|
||||
<ToggleSwitch
|
||||
label={mockToggle.label ?? "Mock server settings"}
|
||||
checked={mockToggle.checked}
|
||||
disabled={disabled || mockToggle.disabled}
|
||||
onChange={mockToggle.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<FormField label="SMTP timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
<FormField label="IMAP timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
{append && (
|
||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||
<ToggleSwitch label="Append successfully sent messages to Sent" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
|
||||
</div>
|
||||
<FormField label="Append folder"><input value={append.folder} disabled={disabled || append.folderDisabled} onChange={(event) => append.onFolderChange(event.target.value)} /></FormField>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(onTestImap || onLookupFolders) && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
{onTestImap && <Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "Testing..." : imapTestLabel}</Button>}
|
||||
{onLookupFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "Looking up..." : folderLookupLabel}</Button>}
|
||||
)}
|
||||
<FormField label="Append target folder" help={appendTargetHelp}>
|
||||
<div className="field-with-action mail-server-folder-field">
|
||||
<input
|
||||
value={appendTargetFolder}
|
||||
disabled={appendTargetDisabled}
|
||||
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
|
||||
placeholder="auto"
|
||||
/>
|
||||
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "Looking up..." : folderLookupLabel}</Button>}
|
||||
</div>
|
||||
</FormField>
|
||||
{canLookupAppendFolders && <MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled || appendTargetDisabled} onUseDetected={onUseDetectedFolder} />}
|
||||
</div>
|
||||
)}
|
||||
{onLookupFolders && <p className="muted small-note">Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.</p>}
|
||||
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
|
||||
<MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled} onUseDetected={onUseDetectedFolder} />
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -278,6 +445,21 @@ function SecuritySelect({ value, disabled, onChange }: { value: string; disabled
|
||||
return <select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
|
||||
}
|
||||
|
||||
function portForSecurityChange(protocol: "smtp" | "imap", currentPort: string | number | null | undefined, currentSecurity: MailServerSecurity, nextSecurity: MailServerSecurity): number | undefined {
|
||||
const current = numberValue(currentPort);
|
||||
const previousDefault = protocol === "smtp" ? defaultSmtpPort(currentSecurity) : defaultImapPort(currentSecurity);
|
||||
if (current !== null && current !== previousDefault) return undefined;
|
||||
return protocol === "smtp" ? defaultSmtpPort(nextSecurity) : defaultImapPort(nextSecurity);
|
||||
}
|
||||
|
||||
function numberValue(value: string | number | null | undefined): number | null {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function stringValue(value: string | number | null | undefined, fallback = ""): string {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return String(value);
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
{hasSystemArea(availableSections) && <Card title="System administration">
|
||||
<div className="admin-overview-grid">
|
||||
{availableSections.has("system-settings") && <AreaLink title="Settings" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
|
||||
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level limiting permissions." onClick={() => onSelect("system-retention")} />}
|
||||
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
|
||||
{availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />}
|
||||
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
|
||||
{availableSections.has("system-groups") && <AreaLink title="Central groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import type { ApiSettings, AuthInfo, MailProfilesUiCapability } from "../../types";
|
||||
import { fetchMe } from "../../api/auth";
|
||||
import Card from "../../components/Card";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
@@ -19,6 +19,7 @@ import AdminAuditPanel from "./AdminAuditPanel";
|
||||
import MailProfilesPanel from "./MailProfilesPanel";
|
||||
import RetentionPoliciesPanel from "./RetentionPoliciesPanel";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
|
||||
type AdminSection =
|
||||
| "overview"
|
||||
@@ -53,12 +54,15 @@ export default function AdminPage({
|
||||
auth: AuthInfo;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
||||
|
||||
const available = useMemo(() => {
|
||||
const sections = new Set<AdminSection>(["overview"]);
|
||||
if (hasScope(auth, "system:settings:read")) {
|
||||
sections.add("system-settings");
|
||||
sections.add("system-retention");
|
||||
sections.add("system-mail-servers");
|
||||
if (mailProfilesAvailable) sections.add("system-mail-servers");
|
||||
}
|
||||
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
|
||||
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
|
||||
@@ -70,7 +74,7 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups");
|
||||
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
|
||||
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
|
||||
if (hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
|
||||
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
|
||||
sections.add("tenant-mail-servers");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-mail-servers");
|
||||
@@ -83,7 +87,7 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
||||
if (hasScope(auth, "audit:read")) sections.add("tenant-audit");
|
||||
return sections;
|
||||
}, [auth]);
|
||||
}, [auth, mailProfilesAvailable]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requestedSection = searchParams.get("section") as AdminSection | null;
|
||||
const active: AdminSection = requestedSection && available.has(requestedSection) ? requestedSection : "overview";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "../../types";
|
||||
import { fetchGroups, fetchUsers } from "../../api/admin";
|
||||
import type { MailProfileScope } from "@govoplan/mail-webui";
|
||||
import { MailProfileScopeManager, type MailProfileTargetOption } from "@govoplan/mail-webui";
|
||||
import Card from "../../components/Card";
|
||||
import AdminPageLayout from "./components/AdminPageLayout";
|
||||
import { adminErrorMessage } from "./adminUtils";
|
||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
@@ -44,11 +44,21 @@ const copy: Record<Props["scopeType"], { title: string; description: string; tar
|
||||
};
|
||||
|
||||
export default function MailProfilesPanel({ settings, scopeType, canWriteProfiles, canManageCredentials, canWritePolicy }: Props) {
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const [targets, setTargets] = useState<MailProfileTargetOption[]>([]);
|
||||
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
|
||||
const [loadingTargets, setLoadingTargets] = useState(Boolean(MailProfileScopeManager) && (scopeType === "user" || scopeType === "group"));
|
||||
const [targetError, setTargetError] = useState("");
|
||||
|
||||
useEffect(() => { void loadTargets(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType]);
|
||||
useEffect(() => {
|
||||
if (!MailProfileScopeManager) {
|
||||
setTargets([]);
|
||||
setLoadingTargets(false);
|
||||
setTargetError("");
|
||||
return;
|
||||
}
|
||||
void loadTargets();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager]);
|
||||
|
||||
async function loadTargets() {
|
||||
if (scopeType !== "user" && scopeType !== "group") {
|
||||
@@ -85,6 +95,16 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
|
||||
|
||||
const labels = copy[scopeType];
|
||||
|
||||
if (!MailProfileScopeManager) {
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description}>
|
||||
<Card title="Mail module unavailable">
|
||||
<p className="muted">Install and enable the Mail module to manage mail server profiles and profile policies.</p>
|
||||
</Card>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError}>
|
||||
<MailProfileScopeManager
|
||||
|
||||
@@ -17,29 +17,29 @@ type Props = {
|
||||
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
|
||||
system: {
|
||||
title: "System retention",
|
||||
description: "Instance-wide privacy retention policy and lower-level limiting permissions.",
|
||||
description: "Instance-wide privacy retention policy and lower-level override permissions.",
|
||||
policyTitle: "System retention policy",
|
||||
policyDescription: "Set concrete system retention values. The Allow limiting toggles decide which fields tenants, owners and campaigns may restrict further."
|
||||
policyDescription: "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override."
|
||||
},
|
||||
tenant: {
|
||||
title: "Tenant retention",
|
||||
description: "Tenant-level privacy and retention limits for the active tenant.",
|
||||
policyTitle: "Tenant retention policy",
|
||||
policyDescription: "Tenant limits may only narrow the system policy. The Allow limiting toggles decide which fields users, groups and campaigns may restrict further."
|
||||
policyDescription: "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override."
|
||||
},
|
||||
user: {
|
||||
title: "User retention",
|
||||
description: "User-scoped retention limits for campaigns owned by users in the active tenant.",
|
||||
targetLabel: "User",
|
||||
policyTitle: "User retention policy",
|
||||
policyDescription: "User limits may only narrow inherited system and tenant policy. The Allow limiting toggles decide which fields user-owned campaigns may restrict further."
|
||||
policyDescription: "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override."
|
||||
},
|
||||
group: {
|
||||
title: "Group retention",
|
||||
description: "Group-scoped retention limits for group-owned campaigns in the active tenant.",
|
||||
targetLabel: "Group",
|
||||
policyTitle: "Group retention policy",
|
||||
policyDescription: "Group limits may only narrow inherited system and tenant policy. The Allow limiting toggles decide which fields group-owned campaigns may restrict further."
|
||||
policyDescription: "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override."
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,11 +9,15 @@ import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
export default function LoginModal({
|
||||
settings,
|
||||
onClose,
|
||||
onLogin
|
||||
onLogin,
|
||||
title = "Sign in",
|
||||
message
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
onClose: () => void;
|
||||
onLogin: (response: LoginResponse) => void;
|
||||
title?: string;
|
||||
message?: string;
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -39,10 +43,11 @@ export default function LoginModal({
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true">
|
||||
<form className="modal-panel" onSubmit={submit}>
|
||||
<header className="modal-header">
|
||||
<h2>Sign in</h2>
|
||||
<h2>{title}</h2>
|
||||
<button className="modal-close" type="button" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="modal-body form-grid">
|
||||
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<FormField label="Email">
|
||||
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||
|
||||
@@ -1,22 +1,149 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Card from "../../components/Card";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { apiFetch, isApiError } from "../../api/client";
|
||||
import { usePlatformModules } from "../../platform/ModuleContext";
|
||||
import type { ApiSettings } from "../../types";
|
||||
|
||||
type DashboardCampaign = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
external_id?: string | null;
|
||||
status?: string | null;
|
||||
updated_at?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
type CampaignListResponse = DashboardCampaign[] | { campaigns?: DashboardCampaign[]; items?: DashboardCampaign[]; results?: DashboardCampaign[] };
|
||||
|
||||
export default function DashboardPage({ settings }: { settings: ApiSettings }) {
|
||||
const modules = usePlatformModules();
|
||||
const campaignsInstalled = modules.some((module) => module.id === "campaigns");
|
||||
const [campaigns, setCampaigns] = useState<DashboardCampaign[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignsInstalled) {
|
||||
setCampaigns([]);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns")
|
||||
.then((response) => {
|
||||
if (!cancelled) setCampaigns(campaignsFromResponse(response));
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (isApiError(reason, 403, 404)) {
|
||||
setCampaigns([]);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [campaignsInstalled, settings]);
|
||||
|
||||
const statusCounts = useMemo(() => countByStatus(campaigns), [campaigns]);
|
||||
const recentCampaigns = useMemo(
|
||||
() => [...campaigns].sort((left, right) => timestamp(right) - timestamp(left)).slice(0, 5),
|
||||
[campaigns],
|
||||
);
|
||||
const activeCampaigns = (statusCounts.active ?? 0) + (statusCounts.draft ?? 0);
|
||||
const completedCampaigns = (statusCounts.completed ?? 0) + (statusCounts.sent ?? 0);
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>Dashboard</h1>
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Tenant overview across installed modules and accessible campaigns.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="warning" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Campaigns" value="0" detail="Connect the API to load data" />
|
||||
<MetricCard label="Queued" value="0" tone="info" />
|
||||
<MetricCard label="Needs review" value="0" tone="warning" />
|
||||
<MetricCard label="Failed" value="0" tone="danger" />
|
||||
<MetricCard label="Installed modules" value={modules.length} tone="info" detail={modules.length ? moduleLabels(modules).join(", ") : "Core only"} />
|
||||
<MetricCard label="Campaigns" value={campaignsInstalled ? campaigns.length : "—"} tone="neutral" detail={campaignsInstalled ? "Accessible to you" : "Campaign module not installed"} />
|
||||
<MetricCard label="Active drafts" value={campaignsInstalled ? activeCampaigns : "—"} tone="warning" detail="Draft or active" />
|
||||
<MetricCard label="Completed" value={campaignsInstalled ? completedCampaigns : "—"} tone="good" detail="Completed or sent" />
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Recommended next action"><p className="muted">Create or open a campaign to continue.</p></Card>
|
||||
<Card title="System status"><p className="muted">API health and queue metrics will appear here.</p></Card>
|
||||
<Card title="Recent campaigns" collapsible>
|
||||
<LoadingFrame loading={loading} label="Loading campaigns...">
|
||||
{!campaignsInstalled && <p className="muted">Install the Campaign module to show campaign activity here.</p>}
|
||||
{campaignsInstalled && recentCampaigns.length === 0 && <p className="muted">No accessible campaigns found.</p>}
|
||||
{recentCampaigns.length > 0 && (
|
||||
<dl className="detail-list">
|
||||
{recentCampaigns.map((campaign) => (
|
||||
<div key={campaign.id}>
|
||||
<dt><StatusBadge status={campaign.status || "draft"} /></dt>
|
||||
<dd>
|
||||
<strong>{campaign.name || campaign.external_id || campaign.id}</strong>
|
||||
<span className="muted"> · {formatDashboardDate(campaign.updated_at ?? campaign.updatedAt)}</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
|
||||
<Card title="Installed modules" collapsible>
|
||||
{modules.length === 0 ? <p className="muted">No feature modules are active.</p> : (
|
||||
<dl className="detail-list">
|
||||
{modules.map((module) => (
|
||||
<div key={module.id}>
|
||||
<dt>{module.id}</dt>
|
||||
<dd><strong>{module.label}</strong><span className="muted"> · v{module.version}</span></dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function campaignsFromResponse(response: CampaignListResponse): DashboardCampaign[] {
|
||||
if (Array.isArray(response)) return response;
|
||||
return response.campaigns ?? response.items ?? response.results ?? [];
|
||||
}
|
||||
|
||||
function countByStatus(campaigns: DashboardCampaign[]): Record<string, number> {
|
||||
return campaigns.reduce<Record<string, number>>((counts, campaign) => {
|
||||
const status = String(campaign.status || "draft").toLowerCase();
|
||||
counts[status] = (counts[status] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function timestamp(campaign: DashboardCampaign): number {
|
||||
const value = campaign.updated_at ?? campaign.updatedAt ?? "";
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatDashboardDate(value?: string | null): string {
|
||||
if (!value) return "not updated";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
|
||||
}
|
||||
|
||||
function moduleLabels(modules: Array<{ label: string }>): string[] {
|
||||
return modules.map((module) => module.label).slice(0, 4);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
getPrivacyRetentionPolicy,
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import EffectivePolicyBlock, { EffectivePolicyValue } from "../../components/EffectivePolicyBlock";
|
||||
import type { PolicySourcePathItem } from "../../components/PolicySourcePath";
|
||||
import FieldLabel from "../../components/help/FieldLabel";
|
||||
import FormField from "../../components/FormField";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
|
||||
@@ -63,6 +63,8 @@ type FieldDefinition = {
|
||||
type RawJsonValue = "inherit" | "keep" | "disable";
|
||||
type AuditDetailValue = "inherit" | PrivacyRetentionPolicy["audit_detail_level"];
|
||||
|
||||
const auditDetailOrder: Record<PrivacyRetentionPolicy["audit_detail_level"], number> = { full: 0, redacted: 1, minimal: 2 };
|
||||
|
||||
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
|
||||
store_raw_campaign_json: true,
|
||||
raw_campaign_json_retention_days: true,
|
||||
@@ -107,10 +109,13 @@ export function RetentionPolicyScopeManager({
|
||||
}: RetentionPolicyScopeManagerProps) {
|
||||
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
|
||||
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
|
||||
const targetSelectionRequired = requiresTarget && !scopeId;
|
||||
const hasSelectableTarget = targetOptions.length > 0;
|
||||
const activeScopeId = scopeId || selectedTargetId || null;
|
||||
const defaultDescription = scopeType === "system"
|
||||
? "System retention defaults and the fields lower levels may limit further."
|
||||
? "System retention defaults and the fields lower levels may override."
|
||||
: "Local retention limits for this scope. Values inherit from the parent unless explicitly narrowed.";
|
||||
const targetEmptyText = `No ${pluralizeLabel(targetLabel.toLowerCase())} available`;
|
||||
|
||||
useEffect(() => {
|
||||
if (scopeId) {
|
||||
@@ -119,35 +124,46 @@ export function RetentionPolicyScopeManager({
|
||||
}
|
||||
if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) {
|
||||
setSelectedTargetId(targetOptions[0].id);
|
||||
return;
|
||||
}
|
||||
if (targetOptions.length === 0 && selectedTargetId) setSelectedTargetId("");
|
||||
}, [scopeId, selectedTargetId, targetOptions]);
|
||||
|
||||
return (
|
||||
<div className="retention-policy-manager">
|
||||
{targetOptions.length > 0 && !scopeId && (
|
||||
{targetSelectionRequired && (
|
||||
<Card title={`${targetLabel} scope`}>
|
||||
<div className="retention-policy-target-row">
|
||||
<FormField label={targetLabel}>
|
||||
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
<select value={selectedTargetId} disabled={!hasSelectableTarget} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
{!hasSelectableTarget && <option value="">{targetEmptyText}</option>}
|
||||
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? `${option.label} (${option.secondary})` : option.label}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
scopeId={activeScopeId}
|
||||
title={title}
|
||||
description={requiresTarget && !activeScopeId ? `Select a ${targetLabel.toLowerCase()} before editing retention.` : (description ?? defaultDescription)}
|
||||
canWrite={canWrite}
|
||||
locked={locked}
|
||||
/>
|
||||
{(!requiresTarget || Boolean(activeScopeId)) && (
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
scopeId={activeScopeId}
|
||||
title={title}
|
||||
description={description ?? defaultDescription}
|
||||
canWrite={canWrite}
|
||||
locked={locked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pluralizeLabel(label: string): string {
|
||||
if (label.endsWith("s")) return label;
|
||||
if (label.endsWith("y")) return `${label.slice(0, -1)}ies`;
|
||||
return `${label}s`;
|
||||
}
|
||||
|
||||
export function RetentionPolicyEditor({
|
||||
settings,
|
||||
scopeType,
|
||||
@@ -171,6 +187,7 @@ export function RetentionPolicyEditor({
|
||||
const scopeReady = !requiresTarget || Boolean(scopeId);
|
||||
const disabled = locked || loading || busy || !canWrite || !scopeReady;
|
||||
const showAllowColumn = scopeType !== "campaign";
|
||||
const showEffectiveColumn = !isSystem && scopeReady;
|
||||
const activeParentPolicy = parentPolicy ? normalizeFullPolicy(parentPolicy) : null;
|
||||
const activeFullPolicy = normalizeFullPolicy({ ...defaultPrivacyPolicy, ...policy, allow_lower_level_limits: { ...defaultAllowLowerLevelLimits, ...(policy.allow_lower_level_limits ?? {}) } });
|
||||
const visibleFields = useMemo(() => fieldDefinitions.filter((field) => isSystem || !field.systemOnly), [isSystem]);
|
||||
@@ -291,29 +308,37 @@ export function RetentionPolicyEditor({
|
||||
<h3>{isSystem ? "System policy" : "Local policy"}</h3>
|
||||
<span className="muted small-note">{localPolicySummary}</span>
|
||||
</div>
|
||||
<div className={`retention-policy-table${showAllowColumn ? " with-allow-column" : ""}`}>
|
||||
<div className="retention-policy-row retention-policy-row-header">
|
||||
<div className={`retention-policy-table policy-table${showEffectiveColumn ? " with-effective-column" : ""}${showAllowColumn ? " with-allow-column" : ""}`}>
|
||||
<div className="retention-policy-row policy-row retention-policy-row-header policy-row-header">
|
||||
<span>Field</span>
|
||||
<span>Value</span>
|
||||
<span>{isSystem ? "Value" : "Local setting"}</span>
|
||||
{showEffectiveColumn && <span>Effective policy</span>}
|
||||
{showAllowColumn && <span>Lower levels</span>}
|
||||
</div>
|
||||
{visibleFields.map((field) => {
|
||||
const fieldLocked = !parentAllows(field.key);
|
||||
const fieldDisabled = disabled || fieldLocked;
|
||||
return (
|
||||
<div className="retention-policy-row" key={field.key}>
|
||||
<div className="retention-policy-field-label">
|
||||
<div className="retention-policy-row policy-row" key={field.key}>
|
||||
<div className="retention-policy-field-label policy-field-label">
|
||||
<strong>{field.label}</strong>
|
||||
{fieldLocked && <small>Locked by parent policy</small>}
|
||||
{field.systemOnly && <small>System-level cleanup scope</small>}
|
||||
</div>
|
||||
<div>{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}</div>
|
||||
<div className="policy-control">{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}</div>
|
||||
{showEffectiveColumn && (
|
||||
<div className="retention-policy-effective-cell policy-effective-cell">
|
||||
<FieldLabel className="retention-policy-effective-value policy-effective-value" help={retentionPolicyPathHelp(field, effectivePolicySources, scopeType)}>
|
||||
{retentionFieldValue(field, effectivePolicy, loading)}
|
||||
</FieldLabel>
|
||||
</div>
|
||||
)}
|
||||
{showAllowColumn && (
|
||||
<ToggleSwitch
|
||||
checked={localAllowsLower(field.key)}
|
||||
disabled={disabled || fieldLocked}
|
||||
onChange={(checked) => setAllowLowerLevelLimit(field.key, checked)}
|
||||
label="Allow limiting"
|
||||
label="Allow override"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -322,28 +347,79 @@ export function RetentionPolicyEditor({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{effectivePolicy && (
|
||||
<EffectivePolicyBlock
|
||||
title="Effective policy"
|
||||
sourcePath={effectivePolicySources.length > 0 ? effectivePolicySources : retentionPolicySourcePath(scopeType)}
|
||||
className="retention-policy-section retention-policy-effective"
|
||||
gridClassName="retention-policy-effective-grid"
|
||||
>
|
||||
<EffectivePolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
|
||||
<EffectivePolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
|
||||
<EffectivePolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
|
||||
<EffectivePolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
|
||||
<EffectivePolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
|
||||
<EffectivePolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
|
||||
<EffectivePolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
|
||||
{showAllowColumn && <EffectivePolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
|
||||
</EffectivePolicyBlock>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type PolicySourceItem = {
|
||||
label: string;
|
||||
policy?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function retentionPolicyPathHelp(field: FieldDefinition, sources: PolicySourcePathItem[], scopeType: PrivacyRetentionPolicyScope): ReactNode {
|
||||
const items = normalizePolicySourceItems(sources.length > 0 ? sources : retentionPolicySourcePath(scopeType));
|
||||
return <PolicyPathHelp lines={retentionPolicyPathLines(field, items)} />;
|
||||
}
|
||||
|
||||
function PolicyPathHelp({ lines }: { lines: string[] }) {
|
||||
return (
|
||||
<span className="policy-path-help">
|
||||
{lines.map((line, index) => <span className="policy-path-help-line" key={`${line}-${index}`}>{line}</span>)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function retentionPolicyPathLines(field: FieldDefinition, items: PolicySourceItem[]): string[] {
|
||||
if (items.length === 0) return ["System: Default"];
|
||||
const lines: string[] = [];
|
||||
for (const [index, item] of items.entries()) {
|
||||
const sourcePolicy = asRecord(item.policy);
|
||||
const value = retentionSourceValue(field, sourcePolicy, index === 0);
|
||||
const locked = asRecord(sourcePolicy.allow_lower_level_limits)[field.key] === false;
|
||||
lines.push(`${policyPathPrefix(index)}${item.label}: ${locked ? `${value} without override` : value}`);
|
||||
if (locked) break;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function retentionSourceValue(field: FieldDefinition, sourcePolicy: Record<string, unknown>, isSystem: boolean): string {
|
||||
if (Object.keys(sourcePolicy).length === 0) return isSystem ? "Default" : "Inherit";
|
||||
if (!isSystem && !(field.key in sourcePolicy)) return "Inherit";
|
||||
if (field.kind === "raw-json") {
|
||||
const value = sourcePolicy.store_raw_campaign_json;
|
||||
return value === false ? "Disabled" : value === true ? "Stored" : "Inherit";
|
||||
}
|
||||
if (field.kind === "audit") {
|
||||
const value = sourcePolicy.audit_detail_level;
|
||||
return value === "full" || value === "redacted" || value === "minimal" ? auditDetailLabel(value) : "Inherit";
|
||||
}
|
||||
const value = sourcePolicy[field.key];
|
||||
return typeof value === "number" ? daysLabel(value) : value === null && isSystem ? daysLabel(null) : "Inherit";
|
||||
}
|
||||
|
||||
function normalizePolicySourceItems(items: PolicySourcePathItem[]): PolicySourceItem[] {
|
||||
return items.map((item) => {
|
||||
if (typeof item === "string") return { label: item };
|
||||
return { label: item.label, policy: item.policy };
|
||||
}).filter((item) => item.label.trim());
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function policyPathPrefix(index: number): string {
|
||||
return index === 0 ? "" : `${" ".repeat(index - 1)}> `;
|
||||
}
|
||||
|
||||
function retentionFieldValue(field: FieldDefinition, policy: PrivacyRetentionPolicy | null, loading: boolean): string {
|
||||
if (!policy) return loading ? "Loading..." : "Unavailable";
|
||||
if (field.kind === "raw-json") return policy.store_raw_campaign_json ? "Stored" : "Disabled";
|
||||
if (field.kind === "audit") return auditDetailLabel(policy.audit_detail_level);
|
||||
return daysLabel(policy[field.key as DayKey]);
|
||||
}
|
||||
|
||||
function renderFieldControl(
|
||||
field: FieldDefinition,
|
||||
fullPolicy: PrivacyRetentionPolicy,
|
||||
@@ -362,13 +438,15 @@ function renderFieldControl(
|
||||
return <RawJsonScopedSelect value={rawJsonValue(patchPolicy.store_raw_campaign_json)} parentStoresRawJson={parentPolicy?.store_raw_campaign_json ?? true} disabled={disabled} onChange={setRawJson} />;
|
||||
}
|
||||
if (field.kind === "audit") {
|
||||
return <AuditDetailSelect value={isSystem ? fullPolicy.audit_detail_level : auditDetailValue(patchPolicy.audit_detail_level)} includeInherit={!isSystem} disabled={disabled} onChange={setAuditDetail} />;
|
||||
return <AuditDetailSelect value={isSystem ? fullPolicy.audit_detail_level : auditDetailValue(patchPolicy.audit_detail_level)} includeInherit={!isSystem} parentValue={isSystem ? null : parentPolicy?.audit_detail_level ?? null} disabled={disabled} onChange={setAuditDetail} />;
|
||||
}
|
||||
const parentDayLimit = !isSystem && parentPolicy ? parentPolicy[field.key as DayKey] : null;
|
||||
return (
|
||||
<RetentionDaysField
|
||||
value={isSystem ? fullPolicy[field.key as DayKey] : patchPolicy[field.key as DayKey]}
|
||||
disabled={disabled}
|
||||
placeholder={isSystem ? "Unlimited" : "Inherit"}
|
||||
max={parentDayLimit}
|
||||
onChange={(value) => setRetentionDays(field.key as DayKey, value)}
|
||||
/>
|
||||
);
|
||||
@@ -393,17 +471,32 @@ function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }:
|
||||
);
|
||||
}
|
||||
|
||||
function RetentionDaysField({ value, disabled, placeholder, onChange }: { value?: number | null; disabled: boolean; placeholder: string; onChange: (value: string) => void }) {
|
||||
return <input type="number" min={0} value={value ?? ""} disabled={disabled} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />;
|
||||
function RetentionDaysField({ value, disabled, placeholder, max, onChange }: { value?: number | null; disabled: boolean; placeholder: string; max?: number | null; onChange: (value: string) => void }) {
|
||||
function handleChange(nextValue: string) {
|
||||
const trimmed = nextValue.trim();
|
||||
if (trimmed === "" || max === null || max === undefined) {
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
onChange(Number.isFinite(parsed) && parsed > max ? String(max) : nextValue);
|
||||
}
|
||||
|
||||
return <input type="number" min={0} max={max ?? undefined} value={value ?? ""} disabled={disabled} placeholder={placeholder} onChange={(event) => handleChange(event.target.value)} />;
|
||||
}
|
||||
|
||||
function AuditDetailSelect({ value, includeInherit, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
|
||||
function AuditDetailSelect({ value, includeInherit, parentValue, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; parentValue?: PrivacyRetentionPolicy["audit_detail_level"] | null; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
|
||||
const minimumOrder = parentValue ? auditDetailOrder[parentValue] : 0;
|
||||
function optionDisabled(option: PrivacyRetentionPolicy["audit_detail_level"]): boolean {
|
||||
return auditDetailOrder[option] < minimumOrder;
|
||||
}
|
||||
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as AuditDetailValue)}>
|
||||
{includeInherit && <option value="inherit">Inherit</option>}
|
||||
<option value="full">Full</option>
|
||||
<option value="redacted">Redacted</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="full" disabled={optionDisabled("full")}>Full</option>
|
||||
<option value="redacted" disabled={optionDisabled("redacted")}>Redacted</option>
|
||||
<option value="minimal" disabled={optionDisabled("minimal")}>Minimal</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -427,13 +520,6 @@ function auditDetailLabel(value: PrivacyRetentionPolicy["audit_detail_level"]):
|
||||
return "Minimal";
|
||||
}
|
||||
|
||||
function allowLowerSummary(allow: PrivacyRetentionLimitPermissions): string {
|
||||
const count = fieldDefinitions.filter((field) => allow[field.key] !== false).length;
|
||||
if (count === fieldDefinitions.length) return "Allowed";
|
||||
if (count === 0) return "Locked";
|
||||
return `${count}/${fieldDefinitions.length} allowed`;
|
||||
}
|
||||
|
||||
function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
|
||||
let count = 0;
|
||||
for (const field of fieldDefinitions) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import type { ApiSettings, AuthInfo, MailProfilesUiCapability } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
@@ -11,7 +11,7 @@ import { apiFetch } from "../../api/client";
|
||||
import { updateProfile } from "../../api/auth";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import { MailProfileScopeManager } from "@govoplan/mail-webui";
|
||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
|
||||
type SettingsSection = "profile" | "mail-profiles" | "interface" | "workspace" | "local-connection" | "notifications";
|
||||
@@ -49,7 +49,9 @@ export default function SettingsPage({
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const canUseMailProfiles = hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles), [canUseMailProfiles]);
|
||||
const requestedSection = searchParams.get("section") as SettingsSection | null;
|
||||
const [active, setActive] = useState<SettingsSection>(settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface");
|
||||
@@ -155,7 +157,7 @@ export default function SettingsPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "mail-profiles" && (
|
||||
{active === "mail-profiles" && MailProfileScopeManager && (
|
||||
<MailProfileScopeManager
|
||||
settings={settings}
|
||||
scopeType="user"
|
||||
|
||||
@@ -3,6 +3,8 @@ export * from "./types";
|
||||
export * from "./api/client";
|
||||
export * from "./api/auth";
|
||||
export * from "./api/platform";
|
||||
export * from "./platform/modules";
|
||||
export * from "./platform/ModuleContext";
|
||||
|
||||
export * from "./utils/permissions";
|
||||
export * from "./utils/fieldHelp";
|
||||
@@ -37,9 +39,11 @@ export type { PolicyLockedHintProps } from "./components/PolicyLockedHint";
|
||||
export { default as StatusBadge } from "./components/StatusBadge";
|
||||
export { default as Stepper } from "./components/Stepper";
|
||||
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
||||
export { UnsavedChangesProvider, useRegisterUnsavedChanges, useUnsavedChanges } from "./components/UnsavedChangesGuard";
|
||||
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard";
|
||||
export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
|
||||
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView } from "./components/mail/MailServerSettingsPanel";
|
||||
export type { MailServerConnectionTestResult, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSettingsPanelProps, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
|
||||
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
|
||||
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsPanelProps, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
|
||||
export { default as FieldLabel } from "./components/help/FieldLabel";
|
||||
export { default as InlineHelp } from "./components/help/InlineHelp";
|
||||
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Settings, Shield } from "lucide-react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { AuthInfo, PlatformNavItem } from "../types";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
|
||||
|
||||
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
|
||||
|
||||
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
|
||||
return [...navItems]
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100))
|
||||
@@ -22,10 +25,25 @@ export default function IconRail({
|
||||
auth?: AuthInfo | null;
|
||||
navItems?: PlatformNavItem[];
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const visibleItems = visibleNavItems(auth, navItems);
|
||||
const items = hasAnyScope(auth, adminReadScopes)
|
||||
? [...visibleItems, { to: "/admin", label: "Admin", icon: Shield }]
|
||||
: visibleItems;
|
||||
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
|
||||
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentRoot = topLevelItems.find((root) => modulePathActive(location.pathname, root));
|
||||
if (!currentRoot || location.pathname === currentRoot) return;
|
||||
const target = `${location.pathname}${location.search}${location.hash}`;
|
||||
setRememberedTargets((current) => {
|
||||
if (current[currentRoot] === target) return current;
|
||||
const next = { ...current, [currentRoot]: target };
|
||||
saveRememberedTargets(next);
|
||||
return next;
|
||||
});
|
||||
}, [location.hash, location.pathname, location.search, topLevelItems]);
|
||||
|
||||
return (
|
||||
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
|
||||
@@ -34,11 +52,15 @@ export default function IconRail({
|
||||
{!compact && (
|
||||
<>
|
||||
<nav className="icon-nav">
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={label}>
|
||||
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
|
||||
</NavLink>
|
||||
))}
|
||||
{items.map(({ to, label, icon: Icon }) => {
|
||||
const target = rememberedTargets[to] ?? to;
|
||||
const active = modulePathActive(location.pathname, to);
|
||||
return (
|
||||
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={label}>
|
||||
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="icon-rail-bottom">
|
||||
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title="Settings">
|
||||
@@ -50,3 +72,34 @@ export default function IconRail({
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function modulePathActive(pathname: string, root: string): boolean {
|
||||
if (root === "/") return pathname === "/";
|
||||
return pathname === root || pathname.startsWith(`${root}/`);
|
||||
}
|
||||
|
||||
function loadRememberedTargets(): Record<string, string> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(MODULE_NAV_STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter((entry): entry is [string, string] => {
|
||||
const [root, target] = entry;
|
||||
return typeof root === "string" && typeof target === "string" && root.startsWith("/") && target.startsWith(`${root}/`);
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveRememberedTargets(targets: Record<string, string>): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
|
||||
} catch {
|
||||
// Remembered navigation is a convenience only.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from
|
||||
import HelpMenu from "./HelpMenu";
|
||||
import LoginModal from "../features/auth/LoginModal";
|
||||
import DismissibleAlert from "../components/DismissibleAlert";
|
||||
import { useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
||||
import { logout, switchTenant } from "../api/auth";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
|
||||
@@ -17,6 +18,7 @@ type Props = {
|
||||
|
||||
export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
const [tenantOpen, setTenantOpen] = useState(false);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
@@ -66,7 +68,7 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
async function performLogout() {
|
||||
try {
|
||||
await logout(settings);
|
||||
} catch {
|
||||
@@ -76,11 +78,7 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
setAccountOpen(false);
|
||||
}
|
||||
|
||||
async function handleTenantSelect(tenant: AuthTenantMembership) {
|
||||
if (!auth || tenant.id === activeTenant?.id || switchingTenantId) {
|
||||
setTenantOpen(false);
|
||||
return;
|
||||
}
|
||||
async function performTenantSelect(tenant: AuthTenantMembership) {
|
||||
setSwitchingTenantId(tenant.id);
|
||||
setTenantError("");
|
||||
try {
|
||||
@@ -97,6 +95,23 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
requestNavigation(() => { void performLogout(); });
|
||||
}
|
||||
|
||||
function handleTenantSelect(tenant: AuthTenantMembership) {
|
||||
if (!auth || tenant.id === activeTenant?.id || switchingTenantId) {
|
||||
setTenantOpen(false);
|
||||
return;
|
||||
}
|
||||
requestNavigation(() => { void performTenantSelect(tenant); });
|
||||
}
|
||||
|
||||
function handleAccountSettings() {
|
||||
setAccountOpen(false);
|
||||
requestNavigation(() => navigate("/settings?section=profile"));
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="titlebar">
|
||||
{auth && activeTenant && showTenantControl && (
|
||||
@@ -152,7 +167,7 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
<strong>{auth.user.display_name || auth.user.email}</strong>
|
||||
<span>{auth.user.email}</span>
|
||||
</div>
|
||||
<button className="dropdown-item" onClick={() => { setAccountOpen(false); navigate("/settings?section=profile"); }}><Settings size={16} /> Account settings</button>
|
||||
<button className="dropdown-item" onClick={handleAccountSettings}><Settings size={16} /> Account settings</button>
|
||||
<button className="dropdown-item" onClick={handleLogout}><LogOut size={16} /> Sign out</button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
21
webui/src/platform/ModuleContext.tsx
Normal file
21
webui/src/platform/ModuleContext.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import type { PlatformWebModule } from "../types";
|
||||
import { moduleInstalled, uiCapability } from "./modules";
|
||||
|
||||
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
|
||||
|
||||
export function PlatformModulesProvider({ modules, children }: { modules: PlatformWebModule[]; children: ReactNode }) {
|
||||
return <PlatformModulesContext.Provider value={modules}>{children}</PlatformModulesContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePlatformModules(): PlatformWebModule[] {
|
||||
return useContext(PlatformModulesContext);
|
||||
}
|
||||
|
||||
export function usePlatformModuleInstalled(moduleId: string): boolean {
|
||||
return moduleInstalled(moduleId, usePlatformModules());
|
||||
}
|
||||
|
||||
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
|
||||
return uiCapability<T>(capabilityName, usePlatformModules());
|
||||
}
|
||||
27
webui/src/platform/moduleLogic.ts
Normal file
27
webui/src/platform/moduleLogic.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
|
||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||
for (const module of modules) {
|
||||
const value = module.uiCapabilities?.[capabilityName];
|
||||
if (value !== undefined) return value as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[]): boolean {
|
||||
return uiCapability(capabilityName, modules) !== null;
|
||||
}
|
||||
|
||||
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[]): boolean {
|
||||
return modules.some((module) => module.id === moduleId);
|
||||
}
|
||||
|
||||
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[]): boolean {
|
||||
const module = modules.find((item) => item.id === moduleId);
|
||||
if (!module) return false;
|
||||
return Boolean(module.dependencies?.includes(dependencyId) || (module.optionalDependencies?.includes(dependencyId) && moduleInstalled(dependencyId, modules)));
|
||||
}
|
||||
|
||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Activity, FileText, Folder, Form, LayoutDashboard, Mail, Mails, Users, type LucideIcon } from "lucide-react";
|
||||
import campaignModule from "@govoplan/campaign-webui";
|
||||
import filesModule from "@govoplan/files-webui";
|
||||
import mailModule from "@govoplan/mail-webui";
|
||||
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
||||
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
||||
import {
|
||||
hasUiCapability as hasUiCapabilityForModules,
|
||||
moduleInstalled as moduleInstalledForModules,
|
||||
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
||||
routeContributionsForModules as routeContributionsForModuleList,
|
||||
uiCapability as uiCapabilityForModules
|
||||
} from "./moduleLogic";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
|
||||
|
||||
export const shellNavItems: PlatformNavItem[] = [
|
||||
{ to: "/dashboard", label: "Dashboard", iconName: "dashboard", order: 10 }
|
||||
];
|
||||
|
||||
const legacyModules: PlatformWebModule[] = [campaignModule, filesModule, mailModule];
|
||||
const localModules: PlatformWebModule[] = installedWebModules;
|
||||
|
||||
const iconByName: Record<string, LucideIcon> = {
|
||||
activity: Activity,
|
||||
@@ -47,6 +52,15 @@ function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavIt
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function runtimeUiCapabilitiesForModule(module: PlatformWebModule, info: PlatformModuleInfo) {
|
||||
const enabledNames = new Set(info.runtime_ui_capabilities ?? []);
|
||||
if (enabledNames.size === 0 || !module.runtimeUiCapabilities) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(module.runtimeUiCapabilities).filter(([name]) => enabledNames.has(name))
|
||||
);
|
||||
}
|
||||
|
||||
function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo): PlatformWebModule {
|
||||
const backendNav = info.frontend?.nav.length ? info.frontend.nav : info.nav;
|
||||
return {
|
||||
@@ -55,14 +69,18 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
version: info.version,
|
||||
dependencies: info.dependencies,
|
||||
optionalDependencies: info.optional_dependencies,
|
||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems
|
||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||
uiCapabilities: {
|
||||
...(module.uiCapabilities ?? {}),
|
||||
...runtimeUiCapabilitiesForModule(module, info)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
|
||||
if (!platformModules?.length) return legacyModules;
|
||||
if (!platformModules?.length) return localModules;
|
||||
|
||||
const localById = new Map(legacyModules.map((module) => [module.id, module]));
|
||||
const localById = new Map(localModules.map((module) => [module.id, module]));
|
||||
return platformModules
|
||||
.filter((module) => module.enabled)
|
||||
.map((module) => {
|
||||
@@ -72,14 +90,29 @@ export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[]
|
||||
.filter((module): module is PlatformWebModule => module !== null);
|
||||
}
|
||||
|
||||
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = legacyModules): boolean {
|
||||
return modules.some((module) => module.id === moduleId);
|
||||
|
||||
export function installedLocalWebModules(): PlatformWebModule[] {
|
||||
return localModules;
|
||||
}
|
||||
|
||||
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = legacyModules): boolean {
|
||||
const module = modules.find((item) => item.id === moduleId);
|
||||
if (!module) return false;
|
||||
return Boolean(module.dependencies?.includes(dependencyId) || (module.optionalDependencies?.includes(dependencyId) && moduleInstalled(dependencyId, modules)));
|
||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = localModules): T | null {
|
||||
return uiCapabilityForModules<T>(capabilityName, modules);
|
||||
}
|
||||
|
||||
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = localModules): boolean {
|
||||
return hasUiCapabilityForModules(capabilityName, modules);
|
||||
}
|
||||
|
||||
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = localModules): boolean {
|
||||
return moduleInstalledForModules(moduleId, modules);
|
||||
}
|
||||
|
||||
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = localModules): boolean {
|
||||
return moduleIntegrationEnabledForModules(moduleId, dependencyId, modules);
|
||||
}
|
||||
|
||||
export function routeContributionsForModules(modules: PlatformWebModule[]) {
|
||||
return routeContributionsForModuleList(modules);
|
||||
}
|
||||
|
||||
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
||||
@@ -88,11 +121,7 @@ export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavIte
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = legacyModules): PlatformNavItem[] {
|
||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules): PlatformNavItem[] {
|
||||
return navItemsForModules(modules).filter((item) => {
|
||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
||||
@@ -100,7 +129,7 @@ export function visibleNavItems(auth: AuthInfo | null | undefined, modules: Plat
|
||||
});
|
||||
}
|
||||
|
||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = legacyModules): string {
|
||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules): string {
|
||||
const preferred = visibleNavItems(auth, modules).find((item) => item.to !== "/dashboard");
|
||||
if (preferred) return preferred.to;
|
||||
if (hasAnyScope(auth, adminReadScopes)) return "/admin";
|
||||
|
||||
@@ -501,6 +501,83 @@
|
||||
.inline-help:focus-visible .inline-help-mark {
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.policy-path-help {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.policy-path-help-line {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Shared policy table structure. Feature styles keep only layout-specific grid tracks. */
|
||||
.policy-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.policy-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.policy-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
.policy-row-header {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.policy-field-label {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.policy-field-label strong,
|
||||
.policy-field-title {
|
||||
color: var(--text-strong);
|
||||
font-weight: 800;
|
||||
}
|
||||
.policy-field-label small,
|
||||
.policy-effective-note {
|
||||
color: var(--muted);
|
||||
}
|
||||
.policy-control select,
|
||||
.policy-control input,
|
||||
.policy-row textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.policy-effective-cell,
|
||||
.policy-lower-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
.policy-effective-value {
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.policy-effective-note {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.policy-row,
|
||||
.policy-table.with-effective-column .policy-row,
|
||||
.policy-table.with-allow-column .policy-row,
|
||||
.policy-table.with-effective-column.with-allow-column .policy-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.policy-row-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.inline-help-bubble {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
@@ -642,6 +719,9 @@
|
||||
.card-collapsible.is-collapsed .card-collapse-toggle svg {
|
||||
transform: none;
|
||||
}
|
||||
.card-collapsible.is-collapsed {
|
||||
align-self: start;
|
||||
}
|
||||
.card-collapse-region {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -794,7 +874,7 @@
|
||||
|
||||
.message-display-header {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-display-header h3 {
|
||||
@@ -803,89 +883,291 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-display-header div {
|
||||
display: grid;
|
||||
grid-template-columns: 70px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
.message-display-fields {
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-display-header span {
|
||||
.message-display-fields div,
|
||||
.message-display-headers dl div {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.message-display-fields dt,
|
||||
.message-display-fields dd,
|
||||
.message-display-headers dt,
|
||||
.message-display-headers dd {
|
||||
display: table-cell;
|
||||
padding: 7px 0px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.message-display-fields div:last-child dt,
|
||||
.message-display-fields div:last-child dd,
|
||||
.message-display-headers dl div:last-child dt,
|
||||
.message-display-headers dl div:last-child dd {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.message-display-fields dt,
|
||||
.message-display-headers dt {
|
||||
width: 96px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-display-header strong {
|
||||
.message-display-fields dd,
|
||||
.message-display-headers dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-display-body {
|
||||
.message-display-body-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-display-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-display-section-heading h4,
|
||||
.message-display-attachments h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-display-body-switch {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.message-display-body-switch button {
|
||||
border: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 5px 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-display-body-switch button:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.message-display-body-switch button.active {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.message-display-body,
|
||||
.message-display-html-frame {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
max-height: 420px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.message-display-body {
|
||||
padding: 12px;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-display-html-frame {
|
||||
height: 420px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.message-display-attachments {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 9px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.message-display-attachments h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-display-attachments div {
|
||||
.message-display-attachments-scroll {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
gap: 9px;
|
||||
max-height: min(360px, 38vh);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 6px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.message-display-attachment-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.message-display-attachment-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.message-display-attachments small {
|
||||
.message-display-attachment-row > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-display-attachment-row strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-display-attachment-row small,
|
||||
.message-display-attachment-archive header span,
|
||||
.message-display-attachment-archive header small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.message-display-attachment-detail {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-display-attachment-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
}
|
||||
|
||||
.message-display-attachment-meta span + span::before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.message-display-attachment-archive {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.message-display-attachment-archive header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-display-attachment-archive header div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.message-display-attachment-archive header strong,
|
||||
.message-display-attachment-archive header small {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.message-display-headers summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.message-display-headers dl {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
display: table;
|
||||
width: 100%;
|
||||
max-height: 280px;
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.message-display-headers dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-display-headers dt {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message-display-headers dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Shared SMTP/IMAP server settings panel. */
|
||||
.mail-server-settings-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.mail-server-segmented-control {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
justify-self: center;
|
||||
width: min(420px, 100%);
|
||||
overflow: hidden;
|
||||
border: 1px solid #c9c3b9;
|
||||
border-radius: 8px;
|
||||
background: #c9c3b9;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
||||
}
|
||||
.mail-server-segmented-tab {
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(#ffffff, #f1efeb);
|
||||
color: #4f4a43;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
||||
transition: background .18s ease, box-shadow .18s ease, color .18s ease;
|
||||
}
|
||||
.mail-server-segmented-tab + .mail-server-segmented-tab {
|
||||
border-left: 1px solid #c9c3b9;
|
||||
}
|
||||
.mail-server-segmented-tab:first-child {
|
||||
border-radius: 7px 0 0 7px;
|
||||
}
|
||||
.mail-server-segmented-tab:last-child {
|
||||
border-radius: 0 7px 7px 0;
|
||||
}
|
||||
.mail-server-segmented-tab:hover,
|
||||
.mail-server-segmented-tab:focus-visible {
|
||||
background: linear-gradient(#ffffff, #e8e5df);
|
||||
color: #4f4a43;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
|
||||
}
|
||||
.mail-server-segmented-tab:focus-visible {
|
||||
outline: 3px solid rgba(82, 130, 177, .22);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.mail-server-segmented-tab.is-active {
|
||||
background: var(--line);
|
||||
color: var(--text-strong);
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.12), inset 0 -1px 0 rgba(255,255,255,.45);
|
||||
}
|
||||
.mail-server-segmented-tab.is-active:hover,
|
||||
.mail-server-segmented-tab.is-active:focus-visible {
|
||||
background: var(--line);
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.14), inset 0 -1px 0 rgba(255,255,255,.45);
|
||||
}
|
||||
.mail-server-settings-view {
|
||||
min-width: 0;
|
||||
}
|
||||
.mail-server-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||
@@ -908,9 +1190,19 @@
|
||||
font-size: 14px;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
.form-grid.compact.mail-server-form-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.mail-server-form-grid {
|
||||
align-items: start;
|
||||
}
|
||||
.mail-server-field-heading {
|
||||
grid-column: 1 / -1;
|
||||
margin: 2px 0 -4px;
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.mail-server-field-span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -919,10 +1211,22 @@
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.mail-server-plain-toggle-row {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.mail-server-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.mail-server-folder-field {
|
||||
align-items: stretch;
|
||||
}
|
||||
.mail-server-folder-field .btn,
|
||||
.mail-server-folder-field .button {
|
||||
min-height: 36px;
|
||||
}
|
||||
.mail-server-folder-chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -944,7 +1248,19 @@
|
||||
@media (max-width: 1000px) {
|
||||
.mail-server-settings-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.form-grid.compact.mail-server-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.form-grid.compact.mail-server-form-grid { grid-template-columns: 1fr; }
|
||||
.mail-server-section-heading.split { align-items: flex-start; flex-direction: column; }
|
||||
.mail-server-segmented-control { width: 100%; }
|
||||
}
|
||||
|
||||
.unsaved-changes-dialog {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.unsaved-changes-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
|
||||
.metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; }
|
||||
.metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; }
|
||||
.dashboard-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.dashboard-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; align-items: start; }
|
||||
.wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; }
|
||||
.wizard-card { width: min(980px, 100%); background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; }
|
||||
.wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; }
|
||||
|
||||
@@ -28,93 +28,31 @@
|
||||
}
|
||||
|
||||
|
||||
.retention-policy-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
.retention-policy-row {
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1fr);
|
||||
}
|
||||
|
||||
.retention-policy-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--line);
|
||||
.retention-policy-table.with-effective-column .retention-policy-row {
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1fr) minmax(190px, 0.8fr);
|
||||
}
|
||||
|
||||
.retention-policy-table.with-allow-column .retention-policy-row {
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1fr) minmax(170px, 0.65fr);
|
||||
}
|
||||
|
||||
.retention-policy-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.retention-policy-row-header {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.retention-policy-field-label {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.retention-policy-field-label strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.retention-policy-field-label small {
|
||||
color: var(--muted);
|
||||
.retention-policy-table.with-effective-column.with-allow-column .retention-policy-row {
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(210px, 1fr) minmax(190px, 0.8fr) minmax(170px, 0.65fr);
|
||||
}
|
||||
|
||||
.retention-run-card .admin-json-preview {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.retention-policy-effective {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.retention-policy-effective-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.retention-policy-effective-grid div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.retention-policy-effective-grid span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.retention-policy-effective-grid strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.retention-policy-effective-grid,
|
||||
.retention-policy-row,
|
||||
.retention-policy-table.with-allow-column .retention-policy-row {
|
||||
.retention-policy-table.with-effective-column .retention-policy-row,
|
||||
.retention-policy-table.with-allow-column .retention-policy-row,
|
||||
.retention-policy-table.with-effective-column.with-allow-column .retention-policy-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { ComponentType, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
|
||||
export type ApiSettings = {
|
||||
apiBaseUrl: string;
|
||||
@@ -74,12 +74,14 @@ export type CampaignWorkspaceSection =
|
||||
| "overview"
|
||||
| "campaign"
|
||||
| "global-settings"
|
||||
| "policies"
|
||||
| "fields"
|
||||
| "recipients"
|
||||
| "recipient-data"
|
||||
| "template"
|
||||
| "files"
|
||||
| "mail-settings"
|
||||
| "mail-policy"
|
||||
| "review"
|
||||
| "report"
|
||||
| "audit"
|
||||
@@ -139,6 +141,8 @@ export type PlatformRouteContribution = {
|
||||
render: (context: PlatformRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
export type PlatformUiCapabilities = Record<string, unknown>;
|
||||
|
||||
export type PlatformWebModule = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -147,6 +151,174 @@ export type PlatformWebModule = {
|
||||
optionalDependencies?: string[];
|
||||
navItems?: PlatformNavItem[];
|
||||
routes?: PlatformRouteContribution[];
|
||||
uiCapabilities?: PlatformUiCapabilities;
|
||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||
};
|
||||
|
||||
export type MailSecurity = "plain" | "tls" | "starttls";
|
||||
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||
|
||||
export type MailProfileTargetOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary?: string | null;
|
||||
};
|
||||
|
||||
export type MailTransportCredentials = {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type MailTransportSettings = {
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
security?: MailSecurity | string | null;
|
||||
timeout_seconds?: number | null;
|
||||
};
|
||||
|
||||
export type MailImapTransportSettings = MailTransportSettings & {
|
||||
sent_folder?: string | null;
|
||||
};
|
||||
|
||||
export type MailServerProfileCredentials = {
|
||||
smtp?: MailTransportCredentials | null;
|
||||
imap?: MailTransportCredentials | null;
|
||||
};
|
||||
|
||||
export type MailServerProfile = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
is_active: boolean;
|
||||
smtp: MailTransportSettings;
|
||||
imap?: MailImapTransportSettings | null;
|
||||
credentials?: MailServerProfileCredentials | null;
|
||||
smtp_password_configured?: boolean;
|
||||
imap_password_configured?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type MailCredentialPolicy = {
|
||||
inherit?: boolean | null;
|
||||
};
|
||||
|
||||
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
|
||||
|
||||
export type MailProfilePolicy = {
|
||||
allowed_profile_ids?: string[] | null;
|
||||
allow_user_profiles?: boolean | null;
|
||||
allow_group_profiles?: boolean | null;
|
||||
allow_campaign_profiles?: boolean | null;
|
||||
smtp_credentials?: MailCredentialPolicy | null;
|
||||
imap_credentials?: MailCredentialPolicy | null;
|
||||
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
|
||||
blacklist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
|
||||
};
|
||||
|
||||
export type MailPolicyValidationInput = {
|
||||
smtpHost?: string | null;
|
||||
imapHost?: string | null;
|
||||
envelopeSender?: string | null;
|
||||
fromHeader?: string | null;
|
||||
recipientDomains?: Array<string | null | undefined> | null;
|
||||
};
|
||||
|
||||
export type MailPolicyValidationMessage = {
|
||||
key: MailProfilePatternKey;
|
||||
label: string;
|
||||
value: string;
|
||||
message: string;
|
||||
severity?: "warning" | "danger";
|
||||
};
|
||||
|
||||
export type MailPolicyValidationFn = (
|
||||
policy: MailProfilePolicy | null | undefined,
|
||||
input: MailPolicyValidationInput,
|
||||
) => MailPolicyValidationMessage[];
|
||||
|
||||
export type MailProfileScopeManagerProps = {
|
||||
settings: ApiSettings;
|
||||
scopeType: MailProfileScope;
|
||||
scopeId?: string | null;
|
||||
targetOptions?: MailProfileTargetOption[];
|
||||
targetLabel?: string;
|
||||
profileTitle?: string;
|
||||
policyTitle?: string;
|
||||
canWriteProfiles: boolean;
|
||||
canManageCredentials: boolean;
|
||||
canWritePolicy: boolean;
|
||||
};
|
||||
|
||||
export type MailProfilePolicyEditorProps = {
|
||||
settings: ApiSettings;
|
||||
scopeType: MailProfileScope;
|
||||
scopeId?: string | null;
|
||||
campaignId?: string | null;
|
||||
profiles: MailServerProfile[];
|
||||
ownerUserId?: string | null;
|
||||
ownerGroupId?: string | null;
|
||||
canWrite: boolean;
|
||||
locked?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
onSaved?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type MailProfilesUiCapability = {
|
||||
MailProfileScopeManager: ComponentType<MailProfileScopeManagerProps>;
|
||||
MailProfilePolicyEditor: ComponentType<MailProfilePolicyEditorProps>;
|
||||
validateMailPolicy?: MailPolicyValidationFn;
|
||||
};
|
||||
|
||||
export type MailDevMailboxUiCapability = {
|
||||
enabled: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type FilesFolderNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: FilesFolderNode[];
|
||||
fileCount: number;
|
||||
persisted: boolean;
|
||||
};
|
||||
|
||||
export type FilesFileActionTarget = {
|
||||
spaceId: string;
|
||||
folderPath: string;
|
||||
};
|
||||
|
||||
export type FilesFolderTreeProps = {
|
||||
nodes: FilesFolderNode[];
|
||||
activeSpaceId: string;
|
||||
spaceId: string;
|
||||
currentFolder: string;
|
||||
dropTargetKey?: string;
|
||||
expandedKeys: Set<string>;
|
||||
onOpen: (spaceId: string, path: string) => void;
|
||||
onToggle: (spaceId: string, path: string) => void;
|
||||
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
|
||||
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FilesFileActionTarget) => void;
|
||||
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FilesFileActionTarget) => Promise<void>;
|
||||
onClearDropState?: () => void;
|
||||
onRequestDragExpand?: (spaceId: string, path: string) => void;
|
||||
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDragEndFolder?: () => void;
|
||||
dragDropEnabled?: boolean;
|
||||
contextMenuEnabled?: boolean;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
export type FilesFileExplorerUiCapability = {
|
||||
FolderTree: ComponentType<FilesFolderTreeProps>;
|
||||
};
|
||||
|
||||
export type PlatformFrontendRouteInfo = {
|
||||
@@ -181,6 +353,7 @@ export type PlatformModuleInfo = {
|
||||
dependencies: string[];
|
||||
optional_dependencies: string[];
|
||||
enabled: boolean;
|
||||
runtime_ui_capabilities?: string[];
|
||||
nav: Array<{
|
||||
path: string;
|
||||
label: string;
|
||||
|
||||
@@ -75,7 +75,6 @@ const FIELD_HELP_BY_LABEL: Record<string, string> = {
|
||||
"Required": "Mark this item as mandatory. Missing required data should become a validation or review issue.",
|
||||
"Subdirs": "Search nested folders below the configured base directory.",
|
||||
"Can override": "Allow recipient-specific values to override the global value for this field.",
|
||||
"Enable IMAP": "Enable IMAP settings so the server can test login, list folders and optionally append sent copies.",
|
||||
"Append successfully sent messages to Sent": "After SMTP send succeeds, append a copy of the message to the selected IMAP Sent folder.",
|
||||
"Append successful messages to Sent via IMAP": "After SMTP send succeeds, append a copy of the message to the configured IMAP Sent folder."
|
||||
};
|
||||
|
||||
9
webui/src/vite-env.d.ts
vendored
9
webui/src/vite-env.d.ts
vendored
@@ -1 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
|
||||
declare module "virtual:govoplan-installed-modules" {
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
|
||||
const installedWebModules: PlatformWebModule[];
|
||||
export { installedWebModules };
|
||||
export default installedWebModules;
|
||||
}
|
||||
|
||||
198
webui/tests/mail-components.test.tsx
Normal file
198
webui/tests/mail-components.test.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
|
||||
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual: unknown, expected: unknown, message = "values should be deeply equal"): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) throw new Error(`${message}: expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import PasswordField from "../src/components/PasswordField";
|
||||
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
|
||||
import MailServerSettingsPanel from "../src/components/mail/MailServerSettingsPanel";
|
||||
|
||||
function noop() {}
|
||||
|
||||
const savedPassword = renderToStaticMarkup(
|
||||
<PasswordField value="" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
|
||||
);
|
||||
assert(savedPassword.includes('placeholder="Saved password configured"'), "saved password placeholder is rendered");
|
||||
assert(!savedPassword.includes("password-field-toggle"), "saved empty password does not show reveal button");
|
||||
|
||||
const typedPassword = renderToStaticMarkup(
|
||||
<PasswordField value="secret" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
|
||||
);
|
||||
assert(typedPassword.includes('aria-label="Show password"'), "typed password shows reveal button");
|
||||
assert(typedPassword.includes("password-field-toggle"), "typed password gets toggle class");
|
||||
|
||||
const settingsPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
smtp={{ host: "smtp.example.org", port: 587, username: "sender", password: "", security: "starttls", timeout_seconds: 30 }}
|
||||
imap={{ host: "imap.example.org", port: 993, username: "sender", password: "", security: "tls", sent_folder: "auto", timeout_seconds: 30 }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
smtpPasswordSaved
|
||||
smtpSavedPasswordPlaceholder="Saved SMTP password"
|
||||
imapPasswordSaved
|
||||
imapSavedPasswordPlaceholder="Saved IMAP password"
|
||||
onTestSmtp={noop}
|
||||
onTestImap={noop}
|
||||
onLookupFolders={noop}
|
||||
folderLookupResult={{
|
||||
ok: true,
|
||||
protocol: "imap",
|
||||
message: "Folders loaded",
|
||||
detected_sent_folder: "Sent",
|
||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
||||
}}
|
||||
onUseDetectedFolder={noop}
|
||||
/>
|
||||
);
|
||||
assert(settingsPanel.includes("Test SMTP"), "SMTP test action is rendered");
|
||||
assert(settingsPanel.includes("Server"), "server heading is rendered");
|
||||
assert(settingsPanel.includes("Credentials"), "credentials heading is rendered");
|
||||
assert(!settingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered");
|
||||
assert(settingsPanel.includes('placeholder="Saved SMTP password"'), "SMTP saved credential placeholder is rendered");
|
||||
|
||||
const imapSettingsPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
initialSection="imap"
|
||||
smtp={{ host: "smtp.example.org", port: 587, username: "sender", password: "", security: "starttls", timeout_seconds: 30 }}
|
||||
imap={{ host: "imap.example.org", port: 993, username: "sender", password: "", security: "tls", sent_folder: "auto", timeout_seconds: 30 }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
imapPasswordSaved
|
||||
imapSavedPasswordPlaceholder="Saved IMAP password"
|
||||
onTestImap={noop}
|
||||
onLookupFolders={noop}
|
||||
folderLookupResult={{
|
||||
ok: true,
|
||||
protocol: "imap",
|
||||
message: "Folders loaded",
|
||||
detected_sent_folder: "Sent",
|
||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
||||
}}
|
||||
onUseDetectedFolder={noop}
|
||||
/>
|
||||
);
|
||||
assert(imapSettingsPanel.includes("Test IMAP"), "IMAP test action is rendered");
|
||||
assert(imapSettingsPanel.includes("Server"), "IMAP server heading is rendered");
|
||||
assert(imapSettingsPanel.includes("Credentials"), "IMAP credentials heading is rendered");
|
||||
assert(!imapSettingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered in IMAP tab");
|
||||
assert(!imapSettingsPanel.includes("Folders..."), "folder lookup action is not rendered in the IMAP tab");
|
||||
assert(imapSettingsPanel.includes('placeholder="Saved IMAP password"'), "IMAP saved credential placeholder is rendered");
|
||||
assert(!imapSettingsPanel.includes("Folders loaded"), "folder lookup result is not rendered in the IMAP tab");
|
||||
assert(!imapSettingsPanel.includes("Default sent folder"), "default sent folder field is not rendered");
|
||||
|
||||
const advancedSettingsPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
initialSection="advanced"
|
||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
||||
imap={{ host: "imap.example.org", security: "tls", sent_folder: "Sent" }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
onLookupFolders={noop}
|
||||
folderLookupResult={{
|
||||
ok: true,
|
||||
protocol: "imap",
|
||||
message: "Folders loaded",
|
||||
detected_sent_folder: "Sent",
|
||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
||||
}}
|
||||
onUseDetectedFolder={noop}
|
||||
/>
|
||||
);
|
||||
assert(advancedSettingsPanel.includes("Append target folder"), "append target folder is the advanced IMAP folder field");
|
||||
assert(advancedSettingsPanel.includes("Folders..."), "folder lookup action is rendered near append target folder");
|
||||
assert(advancedSettingsPanel.includes("Folders loaded"), "folder lookup result is rendered in Advanced");
|
||||
assert(advancedSettingsPanel.includes("Use detected folder"), "detected folder action is rendered near append target folder");
|
||||
assert(advancedSettingsPanel.includes('value="Sent"'), "append target folder uses IMAP sent folder value without append override");
|
||||
|
||||
const disabledAppendFolderPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
initialSection="advanced"
|
||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
||||
imap={{ host: "imap.example.org", security: "tls", sent_folder: "Sent" }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
onLookupFolders={noop}
|
||||
folderLookupResult={{
|
||||
ok: true,
|
||||
protocol: "imap",
|
||||
message: "Folders loaded",
|
||||
detected_sent_folder: "Sent",
|
||||
folders: [{ name: "Sent", flags: ["\\Sent"] }]
|
||||
}}
|
||||
onUseDetectedFolder={noop}
|
||||
append={{
|
||||
enabled: true,
|
||||
folder: "Sent",
|
||||
folderDisabled: true,
|
||||
onEnabledChange: noop,
|
||||
onFolderChange: noop
|
||||
}}
|
||||
/>
|
||||
);
|
||||
assert(!disabledAppendFolderPanel.includes("Folders..."), "folder lookup action is hidden when append target is disabled");
|
||||
assert(!disabledAppendFolderPanel.includes("Use detected folder"), "detected folder action is hidden when append target is disabled");
|
||||
|
||||
const defaultPortSettingsPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
||||
imap={{ host: "imap.example.org", security: "tls" }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
/>
|
||||
);
|
||||
const defaultImapPortSettingsPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
initialSection="imap"
|
||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
||||
imap={{ host: "imap.example.org", security: "tls" }}
|
||||
onSmtpChange={noop}
|
||||
onImapChange={noop}
|
||||
/>
|
||||
);
|
||||
assert(defaultPortSettingsPanel.includes('value="587"'), "SMTP starttls default port is displayed");
|
||||
assert(defaultImapPortSettingsPanel.includes('value="993"'), "IMAP TLS default port is displayed");
|
||||
|
||||
const messageDisplay = renderToStaticMarkup(
|
||||
<MessageDisplayPanel
|
||||
title="Quarterly update"
|
||||
fields={[
|
||||
{ label: "From", value: "Sender <sender@example.org>" },
|
||||
{ label: "Cc", value: null },
|
||||
{ label: "Bcc", value: "Audit <audit@example.org>" }
|
||||
]}
|
||||
bodyText="Plain body"
|
||||
bodyHtml="<p><strong>HTML body</strong></p>"
|
||||
preferredBodyMode="html"
|
||||
attachments={[
|
||||
{ filename: "agenda.pdf", contentType: "application/pdf", sizeBytes: 2048 },
|
||||
{
|
||||
filename: "secret.pdf",
|
||||
contentType: "application/pdf",
|
||||
archiveGroup: "archive-1",
|
||||
archiveLabel: "recipient-files.zip",
|
||||
protected: true,
|
||||
protectionNote: 'Password-protected ZIP using local field "zip_password". Encryption: AES.'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
assert(messageDisplay.includes("Quarterly update"), "message subject is rendered");
|
||||
assert(messageDisplay.includes("Sender <sender@example.org>"), "sender field is rendered");
|
||||
assert(!messageDisplay.includes("<dt>Cc</dt>"), "empty optional CC field is hidden");
|
||||
assert(messageDisplay.includes("<dt>Bcc</dt>"), "present BCC field is rendered");
|
||||
assert(messageDisplay.includes("HTML"), "HTML body switch is rendered");
|
||||
assert(messageDisplay.includes("Text"), "text body switch is rendered");
|
||||
assert(messageDisplay.includes("agenda.pdf"), "direct attachment is rendered");
|
||||
assert(messageDisplay.includes("recipient-files.zip"), "ZIP archive attachment group is rendered");
|
||||
assert(messageDisplay.includes("Password protected"), "ZIP protection badge is rendered");
|
||||
assert(messageDisplay.includes("zip_password"), "ZIP protection note is rendered");
|
||||
65
webui/tests/module-capabilities.test.ts
Normal file
65
webui/tests/module-capabilities.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { PlatformWebModule } from "../src/types";
|
||||
import {
|
||||
hasUiCapability,
|
||||
moduleInstalled,
|
||||
moduleIntegrationEnabled,
|
||||
routeContributionsForModules,
|
||||
uiCapability
|
||||
} from "../src/platform/moduleLogic";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const access: PlatformWebModule = { id: "access", label: "Access", version: "test" };
|
||||
const filesCapability = { kind: "fileExplorer" };
|
||||
const mailCapability = { kind: "mailProfiles" };
|
||||
const files: PlatformWebModule = {
|
||||
id: "files",
|
||||
label: "Files",
|
||||
version: "test",
|
||||
routes: [{ path: "/files", order: 20, render: () => null }],
|
||||
uiCapabilities: { "files.fileExplorer": filesCapability }
|
||||
};
|
||||
const mail: PlatformWebModule = {
|
||||
id: "mail",
|
||||
label: "Mail",
|
||||
version: "test",
|
||||
routes: [{ path: "/mail", order: 30, render: () => null }],
|
||||
uiCapabilities: { "mail.profiles": mailCapability }
|
||||
};
|
||||
const campaigns: PlatformWebModule = {
|
||||
id: "campaigns",
|
||||
label: "Campaigns",
|
||||
version: "test",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["files", "mail"],
|
||||
routes: [{ path: "/campaigns", order: 10, render: () => null }]
|
||||
};
|
||||
|
||||
const cases: Array<{ name: string; modules: PlatformWebModule[]; files: boolean; mail: boolean }> = [
|
||||
{ name: "core-only", modules: [access], files: false, mail: false },
|
||||
{ name: "files-only", modules: [access, files], files: true, mail: false },
|
||||
{ name: "mail-only", modules: [access, mail], files: false, mail: true },
|
||||
{ name: "campaign-only", modules: [access, campaigns], files: false, mail: false },
|
||||
{ name: "campaign-files", modules: [access, campaigns, files], files: true, mail: false },
|
||||
{ name: "campaign-mail", modules: [access, campaigns, mail], files: false, mail: true },
|
||||
{ name: "full-product", modules: [access, campaigns, files, mail], files: true, mail: true }
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const campaignInstalled = moduleInstalled("campaigns", testCase.modules);
|
||||
assert(moduleInstalled("access", testCase.modules), `${testCase.name}: access should be installed`);
|
||||
assert(moduleInstalled("files", testCase.modules) === testCase.files, `${testCase.name}: files installed state`);
|
||||
assert(moduleInstalled("mail", testCase.modules) === testCase.mail, `${testCase.name}: mail installed state`);
|
||||
assert(hasUiCapability("files.fileExplorer", testCase.modules) === testCase.files, `${testCase.name}: files UI capability`);
|
||||
assert(hasUiCapability("mail.profiles", testCase.modules) === testCase.mail, `${testCase.name}: mail UI capability`);
|
||||
assert(moduleIntegrationEnabled("campaigns", "files", testCase.modules) === (campaignInstalled && testCase.files), `${testCase.name}: campaign files integration`);
|
||||
assert(moduleIntegrationEnabled("campaigns", "mail", testCase.modules) === (campaignInstalled && testCase.mail), `${testCase.name}: campaign mail integration`);
|
||||
}
|
||||
|
||||
assert(uiCapability("files.fileExplorer", [access, files]) === filesCapability, "files capability should return the module-provided object");
|
||||
assert(uiCapability("mail.profiles", [access, mail]) === mailCapability, "mail capability should return the module-provided object");
|
||||
|
||||
const routes = routeContributionsForModules([access, files, mail, campaigns]).map((route) => route.path);
|
||||
assert(routes.join(",") === "/campaigns,/files,/mail", "routes should aggregate and sort by contribution order");
|
||||
30
webui/tsconfig.component-tests.json
Normal file
30
webui/tsconfig.component-tests.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM"
|
||||
],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": false,
|
||||
"outDir": ".component-test-build",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"tests/mail-components.test.tsx",
|
||||
"src/components/PasswordField.tsx",
|
||||
"src/components/MessageDisplayPanel.tsx",
|
||||
"src/components/mail/MailServerSettingsPanel.tsx",
|
||||
"src/components/Button.tsx",
|
||||
"src/components/DismissibleAlert.tsx",
|
||||
"src/components/FormField.tsx",
|
||||
"src/components/ToggleSwitch.tsx"
|
||||
]
|
||||
}
|
||||
22
webui/tsconfig.module-tests.json
Normal file
22
webui/tsconfig.module-tests.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": false,
|
||||
"outDir": ".module-test-build",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"tests/module-capabilities.test.ts",
|
||||
"src/platform/moduleLogic.ts",
|
||||
"src/types.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,17 +1,69 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const linkedModulePackages = [
|
||||
const require = createRequire(import.meta.url);
|
||||
const webuiRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const installedModulesVirtualId = "virtual:govoplan-installed-modules";
|
||||
const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`;
|
||||
|
||||
const defaultWebModulePackages = [
|
||||
"@govoplan/campaign-webui",
|
||||
"@govoplan/files-webui",
|
||||
"@govoplan/mail-webui"
|
||||
];
|
||||
|
||||
function configuredWebModulePackages(): string[] {
|
||||
const configured = process.env.GOVOPLAN_WEBUI_MODULE_PACKAGES;
|
||||
if (configured === undefined) return defaultWebModulePackages;
|
||||
return configured.split(",").map((specifier) => specifier.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function packageDirectory(specifier: string): string {
|
||||
const parts = specifier.startsWith("@") ? specifier.split("/") : [specifier];
|
||||
return join(webuiRoot, "node_modules", ...parts);
|
||||
}
|
||||
|
||||
function packageInstalled(specifier: string): boolean {
|
||||
if (existsSync(join(packageDirectory(specifier), "package.json"))) return true;
|
||||
try {
|
||||
require.resolve(specifier);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function availableWebModuleSpecifiers(): string[] {
|
||||
return configuredWebModulePackages().filter(packageInstalled);
|
||||
}
|
||||
|
||||
function govoplanInstalledModulesPlugin(): Plugin {
|
||||
return {
|
||||
name: "govoplan-installed-modules",
|
||||
resolveId(id: string) {
|
||||
if (id === installedModulesVirtualId) return resolvedInstalledModulesVirtualId;
|
||||
return null;
|
||||
},
|
||||
load(id: string) {
|
||||
if (id !== resolvedInstalledModulesVirtualId) return null;
|
||||
const imports = availableWebModuleSpecifiers();
|
||||
return [
|
||||
...imports.map((specifier, index) => `import module${index} from ${JSON.stringify(specifier)};`),
|
||||
`export const installedWebModules = [${imports.map((_specifier, index) => `module${index}`).join(", ")}];`,
|
||||
"export default installedWebModules;"
|
||||
].join("\n");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [govoplanInstalledModulesPlugin(), react()],
|
||||
optimizeDeps: {
|
||||
exclude: linkedModulePackages
|
||||
exclude: availableWebModuleSpecifiers()
|
||||
},
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
|
||||
Reference in New Issue
Block a user