From ce30b4d0548a3e722b1452ba5cff206c5a381a5f Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 24 Jun 2026 16:20:37 +0200 Subject: [PATCH] campaign sending prototype --- README.md | 15 +- docs/MODULE_ARCHITECTURE.md | 176 ++++++++++++ pyproject.toml | 3 + src/govoplan_core/devserver.py | 222 +++++++++++++++ .../security/module_permissions.py | 1 + src/govoplan_core/settings.py | 2 + webui/src/components/ExplorerTree.tsx | 147 ++++++++++ webui/src/components/MessageDisplayPanel.tsx | 90 ++++++ .../mail/MailServerSettingsPanel.tsx | 257 ++++++++++++++++++ webui/src/features/admin/adminUtils.ts | 6 +- webui/src/index.ts | 7 + webui/src/platform/modules.ts | 27 +- webui/src/styles/components.css | 231 ++++++++++++++++ webui/src/types.ts | 5 + webui/src/utils/datetime.ts | 46 ++++ webui/src/utils/permissions.ts | 3 +- webui/vite.config.ts | 9 + 17 files changed, 1233 insertions(+), 14 deletions(-) create mode 100644 docs/MODULE_ARCHITECTURE.md create mode 100644 src/govoplan_core/devserver.py create mode 100644 webui/src/components/ExplorerTree.tsx create mode 100644 webui/src/components/MessageDisplayPanel.tsx create mode 100644 webui/src/components/mail/MailServerSettingsPanel.tsx create mode 100644 webui/src/utils/datetime.ts diff --git a/README.md b/README.md index 2cb7e70..6ffba86 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,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) Modules may define module-specific permissions and policy behavior, but the platform-level permission model and governance hierarchy belong here. @@ -33,14 +34,20 @@ cd /mnt/DATA/git/govoplan-core ./.venv/bin/python -m pip install -r requirements-dev.txt ``` -Run the platform server from core. 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 loads the access/core module only; product configs can enable installed modules. ```bash cd /mnt/DATA/git/govoplan-core -GOVOPLAN_SERVER_CONFIG=app.govoplan_config:get_server_config \ - ./.venv/bin/python -m uvicorn govoplan_core.server.app:app --reload --host 127.0.0.1 --port 8000 +./.venv/bin/python -m govoplan_core.devserver \ + --config app.govoplan_config:get_server_config \ + --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`. + +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. + `requirements-dev.txt` currently links the local `govoplan-files`, `govoplan-mail`, and `govoplan-campaign` checkouts for development. Production deployments should use tagged git dependencies or published packages. ## WebUI development @@ -65,4 +72,4 @@ Backend modules register through the `govoplan.modules` entry point and return a - nav metadata and frontend package metadata - resource ACL providers and tenant summary/delete-veto providers -WebUI modules export a `PlatformWebModule` with nav items and route contributions. Core renders those routes with `settings` and `auth` context. +WebUI modules export a `PlatformWebModule` with nav items and route contributions. Core renders those routes with `settings` and `auth` context. Frontend nav icons must be supplied as core-resolved `iconName` strings, not imported icon components. See [MODULE_ARCHITECTURE.md](docs/MODULE_ARCHITECTURE.md) for the full module-building contract. diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md new file mode 100644 index 0000000..1e6d33c --- /dev/null +++ b/docs/MODULE_ARCHITECTURE.md @@ -0,0 +1,176 @@ +# GovOPlaN Module Architecture + +GovOPlaN is structured as a core platform runner plus installable feature modules. Core owns the runtime shell and cross-cutting primitives. Modules own feature behavior and contribute backend routes, database metadata, permissions, WebUI routes, and navigation metadata. + +## Core Responsibilities + +Core owns: + +- the server entry point and platform configuration +- module discovery, registry validation, route aggregation, and platform metadata APIs +- database engine/session primitives and migration orchestration +- auth, tenants, RBAC, governance, audit, CSRF/API helpers, and secret helpers +- shared WebUI shell components such as `AppShell`, `IconRail`, `DataGrid`, `ExplorerTree`, `MessageDisplayPanel`, dialogs, loading frames, access boundaries, and form primitives +- centralized mapping from serializable icon names to renderable frontend icons + +Core must not import module feature pages or module business logic directly. It should interact with modules through manifests, entry points, metadata, and route contributions. + +## Module Responsibilities + +A module owns one bounded feature area. A module can include both backend and WebUI code in the same repository so feature behavior and frontend integration evolve together. + +A module owns: + +- backend routers and feature services +- SQLAlchemy models for module-owned tables +- module migrations and migration metadata +- module permissions and role templates +- module-specific schemas, policies, and domain rules +- WebUI pages, feature-specific components, API clients, route contributions, and navigation metadata + +A module should not own generic platform UI. If a component is useful outside one module, move it to `@govoplan/core-webui` and parameterize it there before reusing it. + +## Backend Contract + +Backend modules register through the `govoplan.modules` entry point and expose a `ModuleManifest`. + +Example: + +```toml +[project.entry-points."govoplan.modules"] +files = "govoplan_files.backend.manifest:get_manifest" +``` + +The manifest should declare: + +- `id`, `name`, `version` +- required `dependencies` and `optional_dependencies` +- permissions and role templates +- router factory +- migration metadata and script location +- frontend package metadata +- navigation metadata using serializable icon names + +Backend nav metadata must use icon-name strings, not frontend components: + +```python +NavItem( + path="/files", + label="Files", + icon="folder", + required_any=("files:file:read",), + order=40, +) +``` + +## Database And Migrations + +Core owns the database/session lifecycle. Modules access the database through core session dependencies and register their models/migrations through their manifest. + +Rules: + +- Do not create independent database engines in modules. +- Use core session dependencies, base metadata, and migration orchestration. +- Keep module-owned tables and migrations in the module repository. +- Keep cross-module foreign-key assumptions explicit and conservative. +- Register module metadata in `MigrationSpec` so core can discover it. + +## WebUI Contract + +A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions. + +Example: + +```ts +export const filesModule: PlatformWebModule = { + id: "files", + label: "Files", + version: "1.0.0", + dependencies: ["access"], + navItems: [ + { to: "/files", label: "Files", iconName: "folder", anyOf: ["files:file:read"], order: 40 } + ], + routes: [ + { path: "/files", anyOf: ["files:file:read"], order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) } + ] +}; +``` + +WebUI modules receive only the core route context: + +- `settings` +- `auth` + +A module should call its own API client and module-owned backend routes. Shared API helpers should live in core only when they are truly platform-level concerns. + +## Icon Rules + +Icons are resolved centrally by core. + +Modules must provide icon names with `iconName` in frontend nav contributions and `icon` in backend manifest metadata. Modules must not import Lucide icons for navigation metadata. + +Current core icon names include: + +- `activity` +- `campaign` +- `dashboard` +- `file` +- `files` +- `folder` +- `form` +- `mail` +- `reports` +- `users` + +If a module needs a new navigation icon, add the name-to-component mapping in core first, then use the name in backend and frontend metadata. + +## Shared Component Rules + +Use this rule of thumb: + +- If it is platform-level or likely reusable by more than one module, define it in `@govoplan/core-webui` with parameters. +- If it is feature-specific and only meaningful inside one bounded module, keep it in that module. +- Modules must not import components from another feature module. +- If one module needs a component currently owned by another module, promote a generic version into core and replace the old usage with the core component. + +Examples: + +- `ExplorerTree` is core because files, mailboxes, and future modules can all render hierarchical navigation. +- `MessageDisplayPanel` is core because mail, campaign sending, and later audit/review surfaces can display message-like content. +- `MailProfileManagement` remains in the mail module because it is specific to mail transport policies and profiles. + +## Cross-Module Integration + +A module can declare required dependencies and optional dependencies. Optional behavior should be enabled by module presence and permissions, not by importing another module's WebUI internals. + +Rules: + +- Use core module metadata to check whether another module is installed. +- Use backend APIs/events/service contracts for runtime cooperation. +- Keep UI integration declarative where possible: nav items, route contributions, context actions, and explicit extension points. +- Avoid direct imports from one feature module into another feature module unless the imported package is a published API contract designed for that purpose. UI components should be promoted to core instead. + +## Build And Verification + +Backend verification from core: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m compileall src/govoplan_core ../govoplan-files/src/govoplan_files ../govoplan-mail/src/govoplan_mail ../govoplan-campaign/src/govoplan_campaign +``` + +Core WebUI host verification: + +```bash +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. diff --git a/pyproject.toml b/pyproject.toml index a9e73d0..dcebee2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ where = ["src"] [tool.setuptools.package-data] govoplan_core = ["py.typed"] +[project.scripts] +govoplan-devserver = "govoplan_core.devserver:main" + [project.entry-points."govoplan.modules"] access = "govoplan_core.access.manifest:get_manifest" diff --git a/src/govoplan_core/devserver.py b/src/govoplan_core/devserver.py new file mode 100644 index 0000000..93dd20e --- /dev/null +++ b/src/govoplan_core/devserver.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import argparse +import importlib.util +import inspect +import os +import sys +from collections.abc import Iterable, Sequence +from pathlib import Path +from typing import Any + +from govoplan_core.core.discovery import iter_module_entry_points +from govoplan_core.core.modules import ModuleManifest +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.server.config import GovoplanServerConfig, import_object, load_server_config +from govoplan_core.server.registry import build_platform_registry + +DEFAULT_APP = "govoplan_core.server.app:app" +DEFAULT_CONFIG = "govoplan_core.server.default_config:get_server_config" + + +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: + return None + module_name = object_path.split(":", 1)[0] + try: + spec = importlib.util.find_spec(module_name) + except (ImportError, AttributeError, ValueError): + return None + if spec is None or not spec.origin: + return None + + source = Path(spec.origin).resolve() + if source.name == "__init__.py": + package_dir = source.parent + else: + package_dir = source.parent + if package_dir.name == "app": + return package_dir.parent + return 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 + + defaults = { + "DATABASE_URL": f"sqlite:///{runtime_root / 'multimailer-dev.db'}", + "FILE_STORAGE_LOCAL_ROOT": str(runtime_root / "runtime" / "files"), + "MOCK_MAILBOX_DIR": str(runtime_root / "runtime" / "mock-mailbox"), + } + for key, value in defaults.items(): + os.environ.setdefault(key, value) + return runtime_root + + +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 + if not directory.exists(): + return None + + for candidate in (directory, *directory.parents): + if candidate.name == "src": + return candidate + return directory + + +def _source_roots_for_object(value: Any) -> tuple[Path, ...]: + candidates: list[Any] = [value] + if not inspect.isclass(value) and not inspect.ismodule(value): + candidates.append(type(value)) + + roots: list[Path] = [] + for candidate in candidates: + try: + source = inspect.getsourcefile(candidate) or inspect.getfile(candidate) + except (TypeError, OSError): + continue + root = _source_root_for_path(source) + if root is not None: + roots.append(root) + return tuple(roots) + + +def _manifest_source_roots(manifest: ModuleManifest) -> tuple[Path, ...]: + roots: list[Path] = [] + + if manifest.route_factory is not None: + roots.extend(_source_roots_for_object(manifest.route_factory)) + + if manifest.migration_spec is not None and manifest.migration_spec.script_location: + root = _source_root_for_path(manifest.migration_spec.script_location) + if root is not None: + roots.append(root) + + for provider in manifest.resource_acl_providers: + roots.extend(_source_roots_for_object(provider)) + + for provider in manifest.tenant_summary_providers: + roots.extend(_source_roots_for_object(provider)) + + for providers in manifest.delete_veto_providers.values(): + for provider in providers: + roots.extend(_source_roots_for_object(provider)) + + return tuple(roots) + + +def _entry_point_source_roots(enabled_module_ids: set[str]) -> tuple[Path, ...]: + roots: list[Path] = [] + for entry_point in iter_module_entry_points(): + loaded = entry_point.load() + manifest = loaded() if callable(loaded) else loaded + if not isinstance(manifest, ModuleManifest) or manifest.id not in enabled_module_ids: + continue + roots.extend(_source_roots_for_object(loaded)) + return tuple(roots) + + +def _config_source_roots(config_path: str | None) -> tuple[Path, ...]: + object_path = config_path or os.getenv("GOVOPLAN_SERVER_CONFIG") or DEFAULT_CONFIG + try: + loaded = import_object(object_path) + except ModuleNotFoundError: + if config_path or os.getenv("GOVOPLAN_SERVER_CONFIG"): + raise + loaded = import_object(DEFAULT_CONFIG) + return _source_roots_for_object(loaded) + + +def unique_existing_dirs(paths: Iterable[Path | str]) -> list[str]: + seen: set[Path] = set() + result: list[str] = [] + for item in paths: + path = Path(item).resolve() + if path in seen or not path.is_dir(): + continue + seen.add(path) + result.append(str(path)) + return result + + +def build_reload_dirs( + config: GovoplanServerConfig, + *, + config_path: str | None = None, + registry: PlatformRegistry | None = None, + extra_dirs: Sequence[str] = (), +) -> list[str]: + active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories) + manifests = active_registry.manifests() + enabled_module_ids = {manifest.id for manifest in manifests} + + roots: list[Path | str] = [] + roots.extend(_config_source_roots(config_path)) + + for factory in config.manifest_factories: + roots.extend(_source_roots_for_object(factory)) + + roots.extend(_entry_point_source_roots(enabled_module_ids)) + + for manifest in manifests: + roots.extend(_manifest_source_roots(manifest)) + + roots.extend(extra_dirs) + return unique_existing_dirs(roots) + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the GovOPlaN development server with module-aware reload directories.") + parser.add_argument("--config", help="GovoplanServerConfig object path, e.g. app.govoplan_config:get_server_config.") + parser.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string passed to uvicorn. Default: {DEFAULT_APP}.") + parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1.") + 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.") + return parser.parse_args(argv) + + +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) + + 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}") + + try: + import uvicorn + except ModuleNotFoundError as exc: + raise SystemExit("uvicorn is not installed. Install govoplan-core with the server extra or requirements-dev.txt.") from exc + + uvicorn.run( + args.app, + host=args.host, + port=args.port, + reload=not args.no_reload, + reload_dirs=reload_dirs if not args.no_reload else None, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/govoplan_core/security/module_permissions.py b/src/govoplan_core/security/module_permissions.py index 1d14a58..83ac1fd 100644 --- a/src/govoplan_core/security/module_permissions.py +++ b/src/govoplan_core/security/module_permissions.py @@ -39,6 +39,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = { "mail_servers:read": "mail:profile:read", "mail_servers:use": "mail:profile:use", "mail_servers:test": "mail:profile:test", + "mail_servers:mailbox_read": "mail:mailbox:read", "mail_servers:write": "mail:profile:write", "mail_servers:manage_credentials": "mail:secret:manage", } diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index 4b61d03..8e55651 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -19,6 +19,7 @@ class Settings(BaseSettings): tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE") enabled_modules: str = Field(default="access,campaigns,files,mail", alias="ENABLED_MODULES") redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL") + celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED") s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL") s3_region: str = Field(default="garage", alias="S3_REGION") @@ -30,6 +31,7 @@ class Settings(BaseSettings): # production can switch to Garage/S3 without changing API contracts. file_storage_backend: str = Field(default="local", alias="FILE_STORAGE_BACKEND") file_storage_local_root: str = Field(default="runtime/files", alias="FILE_STORAGE_LOCAL_ROOT") + file_storage_local_fallback_roots: str = Field(default="", alias="FILE_STORAGE_LOCAL_FALLBACK_ROOTS") file_storage_s3_endpoint_url: str | None = Field(default=None, alias="FILE_STORAGE_S3_ENDPOINT_URL") file_storage_s3_region: str | None = Field(default=None, alias="FILE_STORAGE_S3_REGION") file_storage_s3_access_key_id: str | None = Field(default=None, alias="FILE_STORAGE_S3_ACCESS_KEY_ID") diff --git a/webui/src/components/ExplorerTree.tsx b/webui/src/components/ExplorerTree.tsx new file mode 100644 index 0000000..bc0979c --- /dev/null +++ b/webui/src/components/ExplorerTree.tsx @@ -0,0 +1,147 @@ +import { Folder, FolderOpen } from "lucide-react"; +import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react"; + +export type ExplorerTreeNodeContext = { + depth: number; + active: boolean; + expanded: boolean; + hasChildren: boolean; + disabled: boolean; +}; + +type ExplorerTreeProps = { + nodes: T[]; + getNodeId: (node: T) => string; + getNodeLabel: (node: T) => string; + getNodeChildren: (node: T) => T[]; + activeId?: string; + expandedIds: Set; + onOpen: (node: T, context: ExplorerTreeNodeContext) => void; + onToggle: (node: T, context: ExplorerTreeNodeContext) => void; + disabled?: boolean; + depth?: number; + className?: string; + renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode; + renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode; + getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined; + getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined; + getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined; + getNodeDraggable?: (node: T, context: ExplorerTreeNodeContext) => boolean; + onContextMenu?: (event: ReactMouseEvent, node: T, context: ExplorerTreeNodeContext) => void; + onDragStart?: (event: ReactDragEvent, node: T, context: ExplorerTreeNodeContext) => void; + onDragEnd?: (event: ReactDragEvent, node: T, context: ExplorerTreeNodeContext) => void; + onDragOver?: (event: ReactDragEvent, node: T, context: ExplorerTreeNodeContext) => void; + onDragLeave?: (event: ReactDragEvent, node: T, context: ExplorerTreeNodeContext) => void; + onDrop?: (event: ReactDragEvent, node: T, context: ExplorerTreeNodeContext) => void; +}; + +export default function ExplorerTree({ + nodes, + getNodeId, + getNodeLabel, + getNodeChildren, + activeId = "", + expandedIds, + onOpen, + onToggle, + disabled = false, + depth = 1, + className = "", + renderToggleIcon, + renderNodeContent, + getNodeWrapClassName, + getNodeWrapStyle, + getNodeButtonClassName, + getNodeDraggable, + onContextMenu, + onDragStart, + onDragEnd, + onDragOver, + onDragLeave, + onDrop +}: ExplorerTreeProps) { + if (nodes.length === 0) return null; + + return ( +
+ {nodes.map((node) => { + const nodeId = getNodeId(node); + const children = getNodeChildren(node); + const hasChildren = children.length > 0; + const expanded = expandedIds.has(nodeId); + const active = activeId === nodeId; + const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled }; + const draggable = getNodeDraggable?.(node, context) ?? false; + + return ( +
+
onContextMenu(event, node, context) : undefined} + onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined} + onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined} + onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined} + onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined} + onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined} + > + + +
+ {hasChildren && expanded && ( + + )} +
+ ); + })} +
+ ); +} diff --git a/webui/src/components/MessageDisplayPanel.tsx b/webui/src/components/MessageDisplayPanel.tsx new file mode 100644 index 0000000..8144ab5 --- /dev/null +++ b/webui/src/components/MessageDisplayPanel.tsx @@ -0,0 +1,90 @@ +import { Paperclip } from "lucide-react"; + +export type MessageDisplayField = { + label: string; + value?: string | null; +}; + +export type MessageDisplayAttachment = { + id?: string | null; + filename?: string | null; + contentType: string; + sizeBytes: number; +}; + +type MessageDisplayPanelProps = { + title?: string | null; + fields?: MessageDisplayField[]; + bodyText?: string | null; + bodyHtml?: string | null; + bodyPreview?: string | null; + headers?: Record; + attachments?: MessageDisplayAttachment[]; + emptyText?: string; +}; + +export default function MessageDisplayPanel({ + title, + fields = [], + bodyText, + bodyHtml, + bodyPreview, + 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) { + return

{emptyText}

; + } + + const body = bodyText || bodyPreview || (bodyHtml ? stripHtml(bodyHtml) : ""); + + return ( +
+
+

{title || "(no subject)"}

+ {fields.filter((field) => field.value).map((field) => ( +
+ {field.label} + {field.value} +
+ ))} +
+ +
{body || "No readable body content."}
+ + {attachments.length ? ( +
+

Attachments

+ {attachments.map((attachment, index) => ( +
+
+ ))} +
+ ) : null} + +
+ Headers +
+ {Object.entries(headers).map(([key, value]) => ( +
{key}
{value}
+ ))} +
+
+
+ ); +} + +function formatBytes(value?: number | null): string { + 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`; +} + +function stripHtml(value: string): string { + return value.replace(//gi, " ").replace(//gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); +} diff --git a/webui/src/components/mail/MailServerSettingsPanel.tsx b/webui/src/components/mail/MailServerSettingsPanel.tsx new file mode 100644 index 0000000..ccdaf16 --- /dev/null +++ b/webui/src/components/mail/MailServerSettingsPanel.tsx @@ -0,0 +1,257 @@ +import type { ReactNode } from "react"; +import Button from "../Button"; +import DismissibleAlert from "../DismissibleAlert"; +import FormField from "../FormField"; +import ToggleSwitch from "../ToggleSwitch"; + +export type MailServerSecurity = "plain" | "tls" | "starttls" | string; + +export type MailServerSmtpSettings = { + host?: string | null; + port?: string | number | null; + username?: string | null; + password?: string | null; + security?: MailServerSecurity | null; + timeout_seconds?: string | number | null; +}; + +export type MailServerImapSettings = MailServerSmtpSettings & { + enabled?: boolean; + sent_folder?: string | null; +}; + +export type MailServerConnectionTestResult = { + ok: boolean; + protocol?: "smtp" | "imap"; + host?: string | null; + port?: number | null; + security?: MailServerSecurity | null; + message: string; + details?: Record | null; +}; + +export type MailServerFolderLookupResult = { + ok: boolean; + protocol?: "imap"; + host?: string | null; + port?: number | null; + security?: MailServerSecurity | null; + message: string; + detected_sent_folder?: string | null; + folders?: { name: string; flags?: string[] }[]; + details?: Record | null; +}; + +export type MailServerSettingsPanelProps = { + smtp: MailServerSmtpSettings; + imap: MailServerImapSettings; + onSmtpChange: (patch: Partial) => void; + onImapChange: (patch: Partial) => void; + onImapEnabledChange?: (enabled: boolean) => void; + smtpDisabled?: boolean; + smtpCredentialDisabled?: boolean; + smtpActionDisabled?: boolean; + imapToggleDisabled?: boolean; + imapServerDisabled?: boolean; + imapCredentialDisabled?: boolean; + imapActionDisabled?: boolean; + smtpTitle?: ReactNode; + imapTitle?: ReactNode; + smtpTestLabel?: string; + imapTestLabel?: string; + folderLookupLabel?: string; + busyAction?: "smtp" | "imap" | "folders" | string | null; + onTestSmtp?: () => void; + onTestImap?: () => void; + onLookupFolders?: () => void; + smtpTestResult?: MailServerConnectionTestResult | null; + imapTestResult?: MailServerConnectionTestResult | null; + folderLookupResult?: MailServerFolderLookupResult | null; + onUseDetectedFolder?: () => void; + useDetectedFolderDisabled?: boolean; + mockToggle?: { + label?: string; + checked: boolean; + disabled?: boolean; + onChange: (checked: boolean) => void; + }; + append?: { + enabled: boolean; + folder: string; + disabled?: boolean; + folderDisabled?: boolean; + onEnabledChange: (enabled: boolean) => void; + onFolderChange: (folder: string) => void; + }; + disabled?: boolean; + className?: string; + floatingResults?: boolean; +}; + +const securityOptions = ["plain", "tls", "starttls"]; + +export default function MailServerSettingsPanel({ + smtp, + imap, + onSmtpChange, + onImapChange, + onImapEnabledChange, + smtpDisabled = false, + smtpCredentialDisabled = smtpDisabled, + smtpActionDisabled = smtpDisabled, + imapToggleDisabled = false, + imapServerDisabled = false, + imapCredentialDisabled = imapServerDisabled, + imapActionDisabled = imapServerDisabled, + smtpTitle = "SMTP login", + imapTitle = "IMAP sent-folder append", + smtpTestLabel = "Test SMTP", + imapTestLabel = "Test IMAP", + folderLookupLabel = "Folders...", + busyAction = null, + onTestSmtp, + onTestImap, + onLookupFolders, + smtpTestResult = null, + imapTestResult = null, + folderLookupResult = null, + onUseDetectedFolder, + useDetectedFolderDisabled = false, + mockToggle, + append, + disabled = false, + className = "", + floatingResults = false +}: MailServerSettingsPanelProps) { + const smtpFieldsDisabled = disabled || smtpDisabled; + const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled; + const smtpActionsDisabled = disabled || smtpActionDisabled; + const imapFieldsDisabled = disabled || imapServerDisabled; + const imapCredentialFieldsDisabled = disabled || imapCredentialDisabled; + const imapActionsDisabled = disabled || imapActionDisabled; + + return ( +
+
+
+
+

{smtpTitle}

+ {mockToggle && ( + + )} +
+
+ onSmtpChange({ host: event.target.value })} /> + onSmtpChange({ port: event.target.value })} /> + onSmtpChange({ username: event.target.value })} /> + onSmtpChange({ password: event.target.value })} /> + onSmtpChange({ security })} /> + onSmtpChange({ timeout_seconds: event.target.value })} /> +
+ {onTestSmtp && ( +
+ +
+ )} + +
+ +
+
+

{imapTitle}

+
+
+ {onImapEnabledChange && ( +
+ +
+ )} + onImapChange({ host: event.target.value })} /> + onImapChange({ port: event.target.value })} /> + onImapChange({ username: event.target.value })} /> + onImapChange({ password: event.target.value })} /> + onImapChange({ security })} /> + onImapChange({ sent_folder: event.target.value })} /> + onImapChange({ timeout_seconds: event.target.value })} /> + {append && ( + <> +
+ +
+ append.onFolderChange(event.target.value)} /> + + )} +
+ {(onTestImap || onLookupFolders) && ( +
+ {onTestImap && } + {onLookupFolders && } +
+ )} + {onLookupFolders &&

Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.

} + + +
+
+
+ ); +} + +export function MailServerActionResult({ result, floating = false }: { result: MailServerConnectionTestResult | null | undefined; floating?: boolean }) { + if (!result) return null; + const authenticated = result.details?.authenticated; + return ( + + {result.message} + {result.ok && typeof authenticated === "boolean" && ( + Authentication: {authenticated ? "credentials accepted" : "not used"}. + )} + + ); +} + +export function MailServerFolderLookupResultView({ + result, + disabled = false, + onUseDetected +}: { + result: MailServerFolderLookupResult | null | undefined; + disabled?: boolean; + onUseDetected?: () => void; +}) { + if (!result) return null; + if (!result.ok) { + return {result.message}; + } + + const folders = result.folders ?? []; + return ( + +

{result.message}

+

Detected Sent folder: {result.detected_sent_folder || "-"}

+ {result.detected_sent_folder && onUseDetected && } + {folders.length > 0 && ( +
+ {folders.slice(0, 12).map((folder) => ( + {folder.name} + ))} + {folders.length > 12 && +{folders.length - 12} more} +
+ )} +
+ ); +} + +function SecuritySelect({ value, disabled, onChange }: { value: string; disabled: boolean; onChange: (value: MailServerSecurity) => void }) { + return ; +} + +function stringValue(value: string | number | null | undefined, fallback = ""): string { + if (value === null || value === undefined) return fallback; + return String(value); +} diff --git a/webui/src/features/admin/adminUtils.ts b/webui/src/features/admin/adminUtils.ts index 20e69dd..43568c1 100644 --- a/webui/src/features/admin/adminUtils.ts +++ b/webui/src/features/admin/adminUtils.ts @@ -1,3 +1,5 @@ +import { formatDateTime as formatPlatformDateTime } from "../../utils/datetime"; + export function adminErrorMessage(error: unknown): string { if (!(error instanceof Error)) return "The administrative operation failed."; const message = error.message; @@ -17,9 +19,7 @@ export function adminErrorMessage(error: unknown): string { } export function formatDateTime(value?: string | null): string { - if (!value) return "Never"; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); + return formatPlatformDateTime(value, { fallback: "Never" }); } export function joinLabels(values: Array<{ name: string }>): string { diff --git a/webui/src/index.ts b/webui/src/index.ts index 2f50a71..36e554a 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -8,6 +8,7 @@ export * from "./utils/permissions"; export * from "./utils/fieldHelp"; export * from "./utils/helpContext"; export * from "./utils/emailAddresses"; +export * from "./utils/datetime"; export * from "./utils/arrayOrder"; export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary"; @@ -19,12 +20,18 @@ export { default as DismissibleAlert } from "./components/DismissibleAlert"; export { default as FormField } from "./components/FormField"; export { default as LoadingFrame } from "./components/LoadingFrame"; export { default as LoadingIndicator } from "./components/LoadingIndicator"; +export { default as ExplorerTree } from "./components/ExplorerTree"; +export type { ExplorerTreeNodeContext } from "./components/ExplorerTree"; export { default as MetricCard } from "./components/MetricCard"; +export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel"; +export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel"; export { default as PageTitle } from "./components/PageTitle"; export { default as StatusBadge } from "./components/StatusBadge"; export { default as Stepper } from "./components/Stepper"; export { default as ToggleSwitch } from "./components/ToggleSwitch"; 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 FieldLabel } from "./components/help/FieldLabel"; export { default as InlineHelp } from "./components/help/InlineHelp"; export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid"; diff --git a/webui/src/platform/modules.ts b/webui/src/platform/modules.ts index a17062e..dc32736 100644 --- a/webui/src/platform/modules.ts +++ b/webui/src/platform/modules.ts @@ -1,4 +1,4 @@ -import { Activity, FileText, Folder, Form, LayoutDashboard, MailCheck, Users, type LucideIcon } from "lucide-react"; +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"; @@ -6,28 +6,41 @@ import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformRouteContri import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions"; export const shellNavItems: PlatformNavItem[] = [ - { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard, order: 10 } + { to: "/dashboard", label: "Dashboard", iconName: "dashboard", order: 10 } ]; const legacyModules: PlatformWebModule[] = [campaignModule, filesModule, mailModule]; const iconByName: Record = { activity: Activity, - campaign: MailCheck, + campaign: Mails, + dashboard: LayoutDashboard, file: Folder, files: Folder, folder: Folder, form: Form, - mail: MailCheck, + mail: Mail, reports: FileText, users: Users }; +export function iconComponentForName(iconName: string | null | undefined): LucideIcon | undefined { + if (!iconName) return undefined; + return iconByName[iconName]; +} + +function resolveNavItemIcon(item: PlatformNavItem): PlatformNavItem { + return { + ...item, + icon: item.icon ?? iconComponentForName(item.iconName) + }; +} + function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavItem { return { to: item.path, label: item.label, - icon: item.icon ? iconByName[item.icon] : undefined, + iconName: item.icon ?? null, allOf: item.required_all, anyOf: item.required_any, order: item.order @@ -70,7 +83,9 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, } export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] { - return [...shellNavItems, ...modules.flatMap((module) => module.navItems ?? [])].sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); + return [...shellNavItems, ...modules.flatMap((module) => module.navItems ?? [])] + .map(resolveNavItemIcon) + .sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); } export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] { diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index 99ad657..314ae4c 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -627,3 +627,234 @@ opacity: 1; background: rgba(255, 255, 255, 0.45); } + + +/* Shared explorer tree and message display surfaces. */ +.explorer-tree-children { + display: grid; + gap: 1px; +} + +.explorer-tree-node-wrap { + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + align-items: center; + gap: 2px; + border-radius: 9px; +} + +.explorer-tree-node, +.explorer-tree-toggle { + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; +} + +.explorer-tree-toggle { + display: grid; + place-items: center; + width: 26px; + height: 32px; + border-radius: 9px; + color: var(--muted); +} + +.explorer-tree-toggle:disabled { + cursor: default; + opacity: .55; +} + +.explorer-tree-node { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + width: 100%; + padding: 8px 9px; + border-radius: 9px; + font: inherit; + text-align: left; + user-select: none; +} + +.explorer-tree-node span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.explorer-tree-node-wrap:hover, +.explorer-tree-node-wrap:focus-within, +.explorer-tree-node-wrap.is-active { + background: rgba(13, 110, 253, .08); + outline: none; +} + +.explorer-tree-node:focus-visible, +.explorer-tree-toggle:focus-visible { + outline: none; +} + +.message-display-panel { + display: grid; + gap: 14px; + min-width: 0; +} + +.message-display-header { + display: grid; + gap: 8px; +} + +.message-display-header h3 { + margin: 0; + color: var(--text-strong); + overflow-wrap: anywhere; +} + +.message-display-header div { + display: grid; + grid-template-columns: 70px minmax(0, 1fr); + gap: 10px; +} + +.message-display-header span { + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.message-display-header strong { + min-width: 0; + overflow-wrap: anywhere; +} + +.message-display-body { + 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); + font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.message-display-attachments { + display: grid; + gap: 8px; +} + +.message-display-attachments h4 { + margin: 0; +} + +.message-display-attachments div { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} + +.message-display-attachments small { + color: var(--muted); +} + +.message-display-headers dl { + display: grid; + gap: 7px; + max-height: 280px; + 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-settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(280px, 1fr)); + gap: 22px; +} +.mail-server-subsection { + display: grid; + align-content: start; + gap: 12px; +} +.mail-server-section-heading.split { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.mail-server-section-heading h3 { + margin: 0; + color: var(--text-strong); + font-size: 14px; + letter-spacing: .01em; +} +.mail-server-form-grid { + align-items: start; +} +.mail-server-field-span { + grid-column: 1 / -1; +} +.mail-server-toggle-row { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); +} +.mail-server-actions { + justify-content: flex-end; + margin-top: 12px; +} +.mail-server-folder-chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} +.mail-server-folder-chip { + display: inline-flex; + align-items: center; + min-height: 24px; + border: 1px solid var(--line); + border-radius: 99px; + background: var(--panel-soft); + padding: 3px 9px; + color: var(--text-strong); + font-size: 12px; + font-weight: 700; +} +@media (max-width: 1000px) { + .mail-server-settings-grid { grid-template-columns: 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; } +} diff --git a/webui/src/types.ts b/webui/src/types.ts index cbfd4d8..90a5b05 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -112,9 +112,14 @@ export type CampaignListItem = { }; +export type PlatformIconName = string; + export type PlatformNavItem = { to: string; label: string; + /** Resolved by core. Modules should provide iconName, not imported icon components. */ + iconName?: PlatformIconName | null; + /** Runtime-renderable icon, assigned by core after resolving iconName. */ icon?: ComponentType<{ size?: number }>; anyOf?: string[]; allOf?: string[]; diff --git a/webui/src/utils/datetime.ts b/webui/src/utils/datetime.ts new file mode 100644 index 0000000..055b6c0 --- /dev/null +++ b/webui/src/utils/datetime.ts @@ -0,0 +1,46 @@ +const HAS_TIMEZONE_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i; + +function normalizeServerDateTime(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return trimmed; + return HAS_TIMEZONE_OFFSET.test(trimmed) ? trimmed : `${trimmed}Z`; +} + +export function parseServerDateTime(value?: string | null): Date | null { + if (!value) return null; + const date = new Date(normalizeServerDateTime(value)); + return Number.isNaN(date.getTime()) ? null : date; +} + +export type FormatDateTimeOptions = Intl.DateTimeFormatOptions & { + fallback?: string; +}; + +export const defaultDateTimeFormatOptions: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short" +}; + +export function formatDateTime(value?: string | null, options: FormatDateTimeOptions = {}): string { + const fallback = options.fallback ?? "—"; + if (!value) return fallback; + const date = parseServerDateTime(value); + if (!date) return value; + const { fallback: _fallback, ...intlOptions } = options; + return date.toLocaleString(undefined, { + ...defaultDateTimeFormatOptions, + ...intlOptions + }); +} + +export function formatDateTimeFromDate(value: Date, options: FormatDateTimeOptions = {}): string { + const { fallback: _fallback, ...intlOptions } = options; + return value.toLocaleString(undefined, { + ...defaultDateTimeFormatOptions, + ...intlOptions + }); +} diff --git a/webui/src/utils/permissions.ts b/webui/src/utils/permissions.ts index 887bca1..2981c45 100644 --- a/webui/src/utils/permissions.ts +++ b/webui/src/utils/permissions.ts @@ -6,7 +6,7 @@ const legacyTenantScopes = [ "recipients:read", "recipients:write", "recipients:import", "recipients:export", "files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete", "files:admin", "reports:read", "reports:export", "reports:send", "audit:read", - "mail_servers:read", "mail_servers:use", "mail_servers:test", "mail_servers:write", "mail_servers:manage_credentials", + "mail_servers:read", "mail_servers:use", "mail_servers:test", "mail_servers:mailbox_read", "mail_servers:write", "mail_servers:manage_credentials", "admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend", "admin:groups:read", "admin:groups:write", "admin:groups:manage_members", "admin:roles:read", "admin:roles:write", "admin:roles:assign", @@ -48,6 +48,7 @@ const legacyToModuleScopes: Record = { "mail_servers:read": "mail:profile:read", "mail_servers:use": "mail:profile:use", "mail_servers:test": "mail:profile:test", + "mail_servers:mailbox_read": "mail:mailbox:read", "mail_servers:write": "mail:profile:write", "mail_servers:manage_credentials": "mail:secret:manage" }; diff --git a/webui/vite.config.ts b/webui/vite.config.ts index df9aa71..dff1896 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -2,8 +2,17 @@ import { fileURLToPath, URL } from "node:url"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +const linkedModulePackages = [ + "@govoplan/campaign-webui", + "@govoplan/files-webui", + "@govoplan/mail-webui" +]; + export default defineConfig({ plugins: [react()], + optimizeDeps: { + exclude: linkedModulePackages + }, resolve: { preserveSymlinks: true, dedupe: ["react", "react-dom", "react-router-dom"],