1 Commits

Author SHA1 Message Date
02564047e9 Release v0.1.2 2026-06-25 19:58:20 +02:00
73 changed files with 4073 additions and 478 deletions

8
.gitignore vendored
View File

@@ -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
View 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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
View 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
View 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
View 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

View File

@@ -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,6 +255,12 @@ 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()
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(

View File

@@ -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(),
}

View File

@@ -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):

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1,3 @@
from govoplan_core.mail.config import ImapConfig, SmtpConfig, TransportSecurity
__all__ = ["ImapConfig", "SmtpConfig", "TransportSecurity"]

View 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

View File

@@ -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,6 +744,9 @@ 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:
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)

View File

@@ -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)

View File

@@ -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),
}

View File

@@ -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)

View File

@@ -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")

View File

@@ -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,9 +378,11 @@ class ApiSmokeTests(unittest.TestCase):
"mail_profile_id": profile_id,
"inherit_smtp_credentials": False,
"inherit_imap_credentials": False,
"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"},
"to": [{"email": "recipient@example.org", "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,8 +2750,10 @@ class ApiSmokeTests(unittest.TestCase):
"server": {
"mail_profile_id": profile_id,
"inherit_smtp_credentials": False,
"credentials": {
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
},
},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"to": [{"email": "recipient@example.org", "type": "to"}],

View File

@@ -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
View 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
View 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()

View File

@@ -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",

View 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);
}
}

View File

@@ -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,10 +42,9 @@ export default function App() {
}
}
function handlePublicLogin(response: LoginResponse) {
function authFromLoginResponse(response: LoginResponse): AuthInfo {
const active = response.active_tenant ?? response.tenant;
updateAuth(
{
return {
user: response.user,
tenant: active,
active_tenant: active,
@@ -49,11 +52,29 @@ export default function App() {
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,6 +138,8 @@ export default function App() {
if (checkingSession) {
return (
<PlatformModulesProvider modules={webModules}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
<div className="public-landing">
<section className="public-card">
@@ -126,25 +149,33 @@ export default function App() {
</section>
</div>
</AppShell>
</UnsavedChangesProvider>
</PlatformModulesProvider>
);
}
if (!auth) {
return (
<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 (
<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>
{reloginMessage && (
<LoginModal
settings={settings}
title="Session expired"
message={reloginMessage}
onClose={() => setReloginMessage("")}
onLogin={handleRelogin}
/>
)}
</AppShell>
</UnsavedChangesProvider>
</PlatformModulesProvider>
);
}

View File

@@ -152,6 +152,7 @@ export type PolicySourceStep = {
scope_id?: string | null;
label: string;
applied_fields?: string[];
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
};
export type PrivacyRetentionPolicyScopeResponse = {

View File

@@ -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();

View File

@@ -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";

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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) => (
{visibleFields.length > 0 && (
<dl className="message-display-fields">
{visibleFields.map((field) => (
<div key={field.label}>
<span>{field.label}</span>
<strong>{field.value}</strong>
<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 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}
{headerEntries.length > 0 && (
<details className="message-display-headers">
<summary>Headers</summary>
<dl>
{Object.entries(headers).map(([key, value]) => (
{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`;

View File

@@ -4,6 +4,7 @@ export type PolicySourcePathItem = string | {
label: string;
applied_fields?: string[];
appliedFields?: string[];
policy?: Record<string, unknown> | null;
};
export type PolicySourcePathProps = {

View 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]);
}

View File

@@ -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,38 +241,82 @@ 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 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>
<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={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="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(smtp.password)}
value={stringValue(smtpCredentialValues.password)}
disabled={smtpCredentialFieldsDisabled}
saved={smtpPasswordSaved}
savedPlaceholder={smtpSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => onSmtpChange({ password })}
onValueChange={(password) => patchSmtpCredentials({ 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">
@@ -178,52 +325,72 @@ export default function MailServerSettingsPanel({
)}
<MailServerActionResult result={smtpTestResult} floating={floatingResults} />
</section>
<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>
)}
{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={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="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(imap.password)}
value={stringValue(imapCredentialValues.password)}
disabled={imapCredentialFieldsDisabled}
saved={imapPasswordSaved}
savedPlaceholder={imapSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => onImapChange({ password })}
onValueChange={(password) => patchImapCredentials({ 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 && (
<>
</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>
</>
)}
<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>
{(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>
{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>
)}
</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);

View File

@@ -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")} />}

View File

@@ -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";

View File

@@ -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

View File

@@ -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."
}
};

View File

@@ -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)} />

View File

@@ -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">
<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);
}

View File

@@ -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>
)}
{(!requiresTarget || Boolean(activeScopeId)) && (
<RetentionPolicyEditor
settings={settings}
scopeType={scopeType}
scopeId={activeScopeId}
title={title}
description={requiresTarget && !activeScopeId ? `Select a ${targetLabel.toLowerCase()} before editing retention.` : (description ?? defaultDescription)}
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, 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;
}
function AuditDetailSelect({ value, includeInherit, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
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) {

View File

@@ -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"

View File

@@ -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";

View File

@@ -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}>
{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.
}
}

View File

@@ -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>
</>
) : (

View 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());
}

View 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));
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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; }

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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."
};

View File

@@ -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;
}

View 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 &lt;sender@example.org&gt;"), "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");

View 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");

View 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"
]
}

View 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"
]
}

View File

@@ -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,