campaign sending prototype
This commit is contained in:
15
README.md
15
README.md
@@ -21,6 +21,7 @@ Canonical policy documents live in `docs/`:
|
|||||||
|
|
||||||
- [RBAC_MANIFEST.md](docs/RBAC_MANIFEST.md)
|
- [RBAC_MANIFEST.md](docs/RBAC_MANIFEST.md)
|
||||||
- [SYSTEM_GOVERNANCE_MANIFEST.md](docs/SYSTEM_GOVERNANCE_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.
|
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
|
./.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
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan-core
|
||||||
GOVOPLAN_SERVER_CONFIG=app.govoplan_config:get_server_config \
|
./.venv/bin/python -m govoplan_core.devserver \
|
||||||
./.venv/bin/python -m uvicorn govoplan_core.server.app:app --reload --host 127.0.0.1 --port 8000
|
--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.
|
`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
|
## WebUI development
|
||||||
@@ -65,4 +72,4 @@ Backend modules register through the `govoplan.modules` entry point and return a
|
|||||||
- nav metadata and frontend package metadata
|
- nav metadata and frontend package metadata
|
||||||
- resource ACL providers and tenant summary/delete-veto providers
|
- 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.
|
||||||
|
|||||||
176
docs/MODULE_ARCHITECTURE.md
Normal file
176
docs/MODULE_ARCHITECTURE.md
Normal file
@@ -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.
|
||||||
@@ -27,6 +27,9 @@ where = ["src"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
govoplan_core = ["py.typed"]
|
govoplan_core = ["py.typed"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
govoplan-devserver = "govoplan_core.devserver:main"
|
||||||
|
|
||||||
[project.entry-points."govoplan.modules"]
|
[project.entry-points."govoplan.modules"]
|
||||||
access = "govoplan_core.access.manifest:get_manifest"
|
access = "govoplan_core.access.manifest:get_manifest"
|
||||||
|
|
||||||
|
|||||||
222
src/govoplan_core/devserver.py
Normal file
222
src/govoplan_core/devserver.py
Normal file
@@ -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:]))
|
||||||
@@ -39,6 +39,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
|||||||
"mail_servers:read": "mail:profile:read",
|
"mail_servers:read": "mail:profile:read",
|
||||||
"mail_servers:use": "mail:profile:use",
|
"mail_servers:use": "mail:profile:use",
|
||||||
"mail_servers:test": "mail:profile:test",
|
"mail_servers:test": "mail:profile:test",
|
||||||
|
"mail_servers:mailbox_read": "mail:mailbox:read",
|
||||||
"mail_servers:write": "mail:profile:write",
|
"mail_servers:write": "mail:profile:write",
|
||||||
"mail_servers:manage_credentials": "mail:secret:manage",
|
"mail_servers:manage_credentials": "mail:secret:manage",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
|
|||||||
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
||||||
enabled_modules: str = Field(default="access,campaigns,files,mail", alias="ENABLED_MODULES")
|
enabled_modules: str = Field(default="access,campaigns,files,mail", alias="ENABLED_MODULES")
|
||||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||||
|
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
||||||
|
|
||||||
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
|
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
|
||||||
s3_region: str = Field(default="garage", alias="S3_REGION")
|
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.
|
# production can switch to Garage/S3 without changing API contracts.
|
||||||
file_storage_backend: str = Field(default="local", alias="FILE_STORAGE_BACKEND")
|
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_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_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_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")
|
file_storage_s3_access_key_id: str | None = Field(default=None, alias="FILE_STORAGE_S3_ACCESS_KEY_ID")
|
||||||
|
|||||||
147
webui/src/components/ExplorerTree.tsx
Normal file
147
webui/src/components/ExplorerTree.tsx
Normal file
@@ -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<T> = {
|
||||||
|
nodes: T[];
|
||||||
|
getNodeId: (node: T) => string;
|
||||||
|
getNodeLabel: (node: T) => string;
|
||||||
|
getNodeChildren: (node: T) => T[];
|
||||||
|
activeId?: string;
|
||||||
|
expandedIds: Set<string>;
|
||||||
|
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<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||||
|
onDragStart?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||||
|
onDragEnd?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||||
|
onDragOver?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||||
|
onDragLeave?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||||
|
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExplorerTree<T>({
|
||||||
|
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<T>) {
|
||||||
|
if (nodes.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={["explorer-tree-children", "file-tree-children", className].filter(Boolean).join(" ")}>
|
||||||
|
{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 (
|
||||||
|
<div key={nodeId}>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"explorer-tree-node-wrap",
|
||||||
|
"file-tree-node-wrap",
|
||||||
|
active ? "is-active" : "",
|
||||||
|
getNodeWrapClassName?.(node, context) ?? ""
|
||||||
|
].filter(Boolean).join(" ")}
|
||||||
|
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
||||||
|
draggable={draggable && !disabled}
|
||||||
|
onContextMenu={onContextMenu ? (event) => 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}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="explorer-tree-toggle file-tree-toggle"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (hasChildren) onToggle(node, context);
|
||||||
|
}}
|
||||||
|
disabled={disabled || !hasChildren}
|
||||||
|
aria-label={`${expanded ? "Collapse" : "Expand"} ${getNodeLabel(node)}`}
|
||||||
|
aria-expanded={hasChildren ? expanded : undefined}
|
||||||
|
>
|
||||||
|
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={["explorer-tree-node", "file-tree-node", getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
||||||
|
onClick={() => onOpen(node, context)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{hasChildren && expanded && (
|
||||||
|
<ExplorerTree
|
||||||
|
nodes={children}
|
||||||
|
getNodeId={getNodeId}
|
||||||
|
getNodeLabel={getNodeLabel}
|
||||||
|
getNodeChildren={getNodeChildren}
|
||||||
|
activeId={activeId}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onToggle={onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
depth={depth + 1}
|
||||||
|
renderToggleIcon={renderToggleIcon}
|
||||||
|
renderNodeContent={renderNodeContent}
|
||||||
|
getNodeWrapClassName={getNodeWrapClassName}
|
||||||
|
getNodeWrapStyle={getNodeWrapStyle}
|
||||||
|
getNodeButtonClassName={getNodeButtonClassName}
|
||||||
|
getNodeDraggable={getNodeDraggable}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
webui/src/components/MessageDisplayPanel.tsx
Normal file
90
webui/src/components/MessageDisplayPanel.tsx
Normal file
@@ -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<string, string>;
|
||||||
|
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 <p className="muted">{emptyText}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = bodyText || bodyPreview || (bodyHtml ? stripHtml(bodyHtml) : "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-display-panel">
|
||||||
|
<div className="message-display-header">
|
||||||
|
<h3>{title || "(no subject)"}</h3>
|
||||||
|
{fields.filter((field) => field.value).map((field) => (
|
||||||
|
<div key={field.label}>
|
||||||
|
<span>{field.label}</span>
|
||||||
|
<strong>{field.value}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre className="message-display-body">{body || "No readable body content."}</pre>
|
||||||
|
|
||||||
|
{attachments.length ? (
|
||||||
|
<div className="message-display-attachments">
|
||||||
|
<h4>Attachments</h4>
|
||||||
|
{attachments.map((attachment, index) => (
|
||||||
|
<div key={attachment.id || `${attachment.filename || "attachment"}-${index}`}>
|
||||||
|
<Paperclip size={14} aria-hidden="true" />
|
||||||
|
<span>{attachment.filename || "unnamed attachment"}</span>
|
||||||
|
<small>{attachment.contentType} - {formatBytes(attachment.sizeBytes)}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<details className="message-display-headers">
|
||||||
|
<summary>Headers</summary>
|
||||||
|
<dl>
|
||||||
|
{Object.entries(headers).map(([key, value]) => (
|
||||||
|
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
257
webui/src/components/mail/MailServerSettingsPanel.tsx
Normal file
257
webui/src/components/mail/MailServerSettingsPanel.tsx
Normal file
@@ -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<string, unknown> | 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<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailServerSettingsPanelProps = {
|
||||||
|
smtp: MailServerSmtpSettings;
|
||||||
|
imap: MailServerImapSettings;
|
||||||
|
onSmtpChange: (patch: Partial<MailServerSmtpSettings>) => void;
|
||||||
|
onImapChange: (patch: Partial<MailServerImapSettings>) => 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 (
|
||||||
|
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
||||||
|
<div className="mail-server-settings-grid">
|
||||||
|
<section className="mail-server-subsection">
|
||||||
|
<div className="mail-server-section-heading split">
|
||||||
|
<h3>{smtpTitle}</h3>
|
||||||
|
{mockToggle && (
|
||||||
|
<ToggleSwitch
|
||||||
|
label={mockToggle.label ?? "Mock server settings"}
|
||||||
|
checked={mockToggle.checked}
|
||||||
|
disabled={disabled || mockToggle.disabled}
|
||||||
|
onChange={mockToggle.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||||
|
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(smtp.port)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Username"><input value={stringValue(smtp.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ username: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Password"><input type="password" value={stringValue(smtp.password)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ password: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Security"><SecuritySelect value={stringValue(smtp.security, "starttls")} disabled={smtpFieldsDisabled} onChange={(security) => onSmtpChange({ security })} /></FormField>
|
||||||
|
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||||
|
</div>
|
||||||
|
{onTestSmtp && (
|
||||||
|
<div className="button-row compact-actions mail-server-actions">
|
||||||
|
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "Testing..." : smtpTestLabel}</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MailServerActionResult result={smtpTestResult} floating={floatingResults} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(imap.port)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Username"><input value={stringValue(imap.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ username: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Password"><input type="password" value={stringValue(imap.password)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ password: event.target.value })} /></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 className="mail-server-field-span mail-server-toggle-row">
|
||||||
|
<ToggleSwitch label="Append successfully sent messages to Sent" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
|
||||||
|
</div>
|
||||||
|
<FormField label="Append folder"><input value={append.folder} disabled={disabled || append.folderDisabled} onChange={(event) => append.onFolderChange(event.target.value)} /></FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(onTestImap || onLookupFolders) && (
|
||||||
|
<div className="button-row compact-actions mail-server-actions">
|
||||||
|
{onTestImap && <Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "Testing..." : imapTestLabel}</Button>}
|
||||||
|
{onLookupFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "Looking up..." : folderLookupLabel}</Button>}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MailServerActionResult({ result, floating = false }: { result: MailServerConnectionTestResult | null | undefined; floating?: boolean }) {
|
||||||
|
if (!result) return null;
|
||||||
|
const authenticated = result.details?.authenticated;
|
||||||
|
return (
|
||||||
|
<DismissibleAlert tone={result.ok ? "success" : "danger"} resetKey={`${result.ok}:${result.message}`} floating={floating}>
|
||||||
|
{result.message}
|
||||||
|
{result.ok && typeof authenticated === "boolean" && (
|
||||||
|
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
|
||||||
|
)}
|
||||||
|
</DismissibleAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MailServerFolderLookupResultView({
|
||||||
|
result,
|
||||||
|
disabled = false,
|
||||||
|
onUseDetected
|
||||||
|
}: {
|
||||||
|
result: MailServerFolderLookupResult | null | undefined;
|
||||||
|
disabled?: boolean;
|
||||||
|
onUseDetected?: () => void;
|
||||||
|
}) {
|
||||||
|
if (!result) return null;
|
||||||
|
if (!result.ok) {
|
||||||
|
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = result.folders ?? [];
|
||||||
|
return (
|
||||||
|
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
||||||
|
<p>{result.message}</p>
|
||||||
|
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "-"}</strong></p>
|
||||||
|
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
|
||||||
|
{folders.length > 0 && (
|
||||||
|
<div className="mail-server-folder-chip-list">
|
||||||
|
{folders.slice(0, 12).map((folder) => (
|
||||||
|
<span className="mail-server-folder-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
|
||||||
|
))}
|
||||||
|
{folders.length > 12 && <span className="mail-server-folder-chip">+{folders.length - 12} more</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DismissibleAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecuritySelect({ value, disabled, onChange }: { value: string; disabled: boolean; onChange: (value: MailServerSecurity) => void }) {
|
||||||
|
return <select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: string | number | null | undefined, fallback = ""): string {
|
||||||
|
if (value === null || value === undefined) return fallback;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatDateTime as formatPlatformDateTime } from "../../utils/datetime";
|
||||||
|
|
||||||
export function adminErrorMessage(error: unknown): string {
|
export function adminErrorMessage(error: unknown): string {
|
||||||
if (!(error instanceof Error)) return "The administrative operation failed.";
|
if (!(error instanceof Error)) return "The administrative operation failed.";
|
||||||
const message = error.message;
|
const message = error.message;
|
||||||
@@ -17,9 +19,7 @@ export function adminErrorMessage(error: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateTime(value?: string | null): string {
|
export function formatDateTime(value?: string | null): string {
|
||||||
if (!value) return "Never";
|
return formatPlatformDateTime(value, { fallback: "Never" });
|
||||||
const date = new Date(value);
|
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function joinLabels(values: Array<{ name: string }>): string {
|
export function joinLabels(values: Array<{ name: string }>): string {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export * from "./utils/permissions";
|
|||||||
export * from "./utils/fieldHelp";
|
export * from "./utils/fieldHelp";
|
||||||
export * from "./utils/helpContext";
|
export * from "./utils/helpContext";
|
||||||
export * from "./utils/emailAddresses";
|
export * from "./utils/emailAddresses";
|
||||||
|
export * from "./utils/datetime";
|
||||||
export * from "./utils/arrayOrder";
|
export * from "./utils/arrayOrder";
|
||||||
|
|
||||||
export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
|
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 FormField } from "./components/FormField";
|
||||||
export { default as LoadingFrame } from "./components/LoadingFrame";
|
export { default as LoadingFrame } from "./components/LoadingFrame";
|
||||||
export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
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 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 PageTitle } from "./components/PageTitle";
|
||||||
export { default as StatusBadge } from "./components/StatusBadge";
|
export { default as StatusBadge } from "./components/StatusBadge";
|
||||||
export { default as Stepper } from "./components/Stepper";
|
export { default as Stepper } from "./components/Stepper";
|
||||||
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
||||||
export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
|
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 FieldLabel } from "./components/help/FieldLabel";
|
||||||
export { default as InlineHelp } from "./components/help/InlineHelp";
|
export { default as InlineHelp } from "./components/help/InlineHelp";
|
||||||
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
|
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
|
||||||
|
|||||||
@@ -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 campaignModule from "@govoplan/campaign-webui";
|
||||||
import filesModule from "@govoplan/files-webui";
|
import filesModule from "@govoplan/files-webui";
|
||||||
import mailModule from "@govoplan/mail-webui";
|
import mailModule from "@govoplan/mail-webui";
|
||||||
@@ -6,28 +6,41 @@ import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformRouteContri
|
|||||||
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
|
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
|
||||||
|
|
||||||
export const shellNavItems: PlatformNavItem[] = [
|
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 legacyModules: PlatformWebModule[] = [campaignModule, filesModule, mailModule];
|
||||||
|
|
||||||
const iconByName: Record<string, LucideIcon> = {
|
const iconByName: Record<string, LucideIcon> = {
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
campaign: MailCheck,
|
campaign: Mails,
|
||||||
|
dashboard: LayoutDashboard,
|
||||||
file: Folder,
|
file: Folder,
|
||||||
files: Folder,
|
files: Folder,
|
||||||
folder: Folder,
|
folder: Folder,
|
||||||
form: Form,
|
form: Form,
|
||||||
mail: MailCheck,
|
mail: Mail,
|
||||||
reports: FileText,
|
reports: FileText,
|
||||||
users: Users
|
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 {
|
function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavItem {
|
||||||
return {
|
return {
|
||||||
to: item.path,
|
to: item.path,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
icon: item.icon ? iconByName[item.icon] : undefined,
|
iconName: item.icon ?? null,
|
||||||
allOf: item.required_all,
|
allOf: item.required_all,
|
||||||
anyOf: item.required_any,
|
anyOf: item.required_any,
|
||||||
order: item.order
|
order: item.order
|
||||||
@@ -70,7 +83,9 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
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[] {
|
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||||
|
|||||||
@@ -627,3 +627,234 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: rgba(255, 255, 255, 0.45);
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,9 +112,14 @@ export type CampaignListItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type PlatformIconName = string;
|
||||||
|
|
||||||
export type PlatformNavItem = {
|
export type PlatformNavItem = {
|
||||||
to: string;
|
to: string;
|
||||||
label: 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 }>;
|
icon?: ComponentType<{ size?: number }>;
|
||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
|
|||||||
46
webui/src/utils/datetime.ts
Normal file
46
webui/src/utils/datetime.ts
Normal file
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ const legacyTenantScopes = [
|
|||||||
"recipients:read", "recipients:write", "recipients:import", "recipients:export",
|
"recipients:read", "recipients:write", "recipients:import", "recipients:export",
|
||||||
"files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete", "files:admin",
|
"files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete", "files:admin",
|
||||||
"reports:read", "reports:export", "reports:send", "audit:read",
|
"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:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
|
||||||
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
|
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
|
||||||
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
|
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
|
||||||
@@ -48,6 +48,7 @@ const legacyToModuleScopes: Record<string, string> = {
|
|||||||
"mail_servers:read": "mail:profile:read",
|
"mail_servers:read": "mail:profile:read",
|
||||||
"mail_servers:use": "mail:profile:use",
|
"mail_servers:use": "mail:profile:use",
|
||||||
"mail_servers:test": "mail:profile:test",
|
"mail_servers:test": "mail:profile:test",
|
||||||
|
"mail_servers:mailbox_read": "mail:mailbox:read",
|
||||||
"mail_servers:write": "mail:profile:write",
|
"mail_servers:write": "mail:profile:write",
|
||||||
"mail_servers:manage_credentials": "mail:secret:manage"
|
"mail_servers:manage_credentials": "mail:secret:manage"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,17 @@ import { fileURLToPath, URL } from "node:url";
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
const linkedModulePackages = [
|
||||||
|
"@govoplan/campaign-webui",
|
||||||
|
"@govoplan/files-webui",
|
||||||
|
"@govoplan/mail-webui"
|
||||||
|
];
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: linkedModulePackages
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
preserveSymlinks: true,
|
preserveSymlinks: true,
|
||||||
dedupe: ["react", "react-dom", "react-router-dom"],
|
dedupe: ["react", "react-dom", "react-router-dom"],
|
||||||
|
|||||||
Reference in New Issue
Block a user