intermittent commit

This commit is contained in:
2026-07-14 13:22:10 +02:00
parent 8aa1943581
commit e6f7c45f0a
76 changed files with 6039 additions and 2188 deletions

View File

@@ -97,6 +97,20 @@ New backend code should import policy-owned retention behavior from
`govoplan-policy` or request the capability, not add new implementation logic `govoplan-policy` or request the capability, not add new implementation logic
to core. to core.
The retention API DTOs live in `govoplan_core.privacy.schemas`.
`PrivacyRetentionPolicyItem`, `PrivacyRetentionPolicyPatchItem`,
`RETENTION_POLICY_FIELD_KEYS`, and `default_allow_lower_level_limits()` are
platform contracts because admin, access compatibility, and policy routes expose
the same stable retention payload shape. The policy engine's internal
`PrivacyRetentionPolicy` and `PrivacyRetentionPolicyPatch` models stay in
`govoplan-policy`, because they carry implementation validators and merge
behavior that are not generic API contracts.
Tenant administration DTOs remain owned by `govoplan-tenancy`; access keeps
matching compatibility DTOs only for its legacy admin surface. Admin overview
responses remain module-local because the same counters are exposed from
different menu contexts and are not yet a separately versioned platform API.
## Frontend Contract ## Frontend Contract
Policy UIs must: Policy UIs must:

View File

@@ -126,7 +126,6 @@ are not listed in `requirements-release.txt` or `webui/package.release.json`.
Current tag-only module repositories: Current tag-only module repositories:
- `govoplan-addresses`
- `govoplan-appointments` - `govoplan-appointments`
- `govoplan-cases` - `govoplan-cases`
- `govoplan-connectors` - `govoplan-connectors`

View File

@@ -38,6 +38,7 @@ contestability, responsibility, and traceability at the point of action.
| UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit | | UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit |
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records | | UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments | | UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
## Confirmed Implementation Decisions ## Confirmed Implementation Decisions
@@ -138,6 +139,23 @@ adaptive form, not force a linear wizard.
- Wizard shells remain available for assisted setup, first-run guidance, - Wizard shells remain available for assisted setup, first-run guidance,
imports, discovery-heavy flows, and operational preflight workflows. imports, discovery-heavy flows, and operational preflight workflows.
### DUE-008: Platform Theme Contract
Decision: the WebUI shell exposes a small, stable appearance contract based on
shared CSS tokens and persisted user preference selection.
- Core applies `system`, `light`, and `dark` preferences at the document root.
- Core owns shared tokens such as `--bg`, `--bar`, `--panel`, `--surface`,
`--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic
status colors, radii, shadows, and disabled-control colors.
- Modules must style new UI with these tokens and shared controls. Module-local
CSS may tune layout and spacing, but it must not introduce a separate
appearance system.
- Appearance controls live in user settings first. Tenant defaults and policy
enforcement can be added later without changing the token contract.
- Visual preview in settings is illustrative; it must reflect token families,
not become a second theme implementation.
## Implementation Sequence ## Implementation Sequence
| Phase | Scope | Output | | Phase | Scope | Output |

View File

@@ -45,7 +45,7 @@ Remediation applied on 2026-07-09:
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to - upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
`pyzipper==0.4.0` `pyzipper==0.4.0`
- upgraded the local audit environment to `pip==26.1.2` - upgraded the local audit environment to `pip==26.1.2`
- removed the obsolete local `govoplan-module-multimailer` editable install - removed the obsolete local mailer-module editable install
from the audit environment so the audit reflects the split module product from the audit environment so the audit reflects the split module product
Post-remediation result: Post-remediation result:

View File

@@ -94,6 +94,28 @@ class UserInfo(BaseModel):
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences) ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
class AuthSessionUserInfo(BaseModel):
id: str
account_id: str
email: str
display_name: str | None = None
tenant_display_name: str | None = None
is_tenant_admin: bool = False
password_reset_required: bool = False
class AuthSessionResponse(BaseModel):
authenticated: bool = True
auth_method: Literal["session", "api_key"] = "session"
user: AuthSessionUserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
session_id: str | None = None
api_key_id: str | None = None
expires_at: datetime | None = None
class ProfileUpdateRequest(BaseModel): class ProfileUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -143,6 +165,40 @@ class PrincipalContextInfo(BaseModel):
display_name: str | None = None display_name: str | None = None
class AuthShellResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
principal: PrincipalContextInfo | None = None
profile_loaded: bool = False
roles_loaded: bool = False
groups_loaded: bool = False
class AuthProfileResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
profile_loaded: bool = True
class AuthRolesResponse(BaseModel):
roles: list[RoleInfo] = Field(default_factory=list)
roles_loaded: bool = True
class AuthGroupsResponse(BaseModel):
groups: list[GroupInfo] = Field(default_factory=list)
groups_loaded: bool = True
class LoginResponse(BaseModel): class LoginResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
@@ -159,6 +215,9 @@ class LoginResponse(BaseModel):
available_languages: list[LanguageInfo] = Field(default_factory=list) available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list) enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en" default_language: str = "en"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True
class MeResponse(BaseModel): class MeResponse(BaseModel):
@@ -174,3 +233,6 @@ class MeResponse(BaseModel):
available_languages: list[LanguageInfo] = Field(default_factory=list) available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list) enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en" default_language: str = "en"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True

View File

@@ -5,6 +5,8 @@ from celery import Celery
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime from govoplan_core.core.runtime import configure_runtime
from govoplan_core.settings import settings from govoplan_core.settings import settings
from govoplan_core.db.session import configure_database from govoplan_core.db.session import configure_database
@@ -13,7 +15,7 @@ from govoplan_core.server.registry import available_module_manifests, build_plat
configure_database(settings.database_url) configure_database(settings.database_url)
celery = Celery( celery = Celery(
"multimailer", "govoplan",
broker=settings.redis_url, broker=settings.redis_url,
backend=settings.redis_url, backend=settings.redis_url,
) )
@@ -21,8 +23,10 @@ celery = Celery(
celery.conf.update( celery.conf.update(
task_default_queue="default", task_default_queue="default",
task_routes={ task_routes={
"multimailer.send_email": {"queue": "send_email"}, "govoplan.campaigns.send_email": {"queue": "send_email"},
"multimailer.append_sent": {"queue": "append_sent"}, "govoplan.campaigns.append_sent": {"queue": "append_sent"},
"govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
}, },
worker_prefetch_multiplier=1, worker_prefetch_multiplier=1,
task_acks_late=True, task_acks_late=True,
@@ -30,12 +34,12 @@ celery.conf.update(
) )
@celery.task(name="multimailer.ping") @celery.task(name="govoplan.ping")
def ping(): def ping():
return "pong" return "pong"
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider: def _platform_registry() -> PlatformRegistry:
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules) raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules) candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True) available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
@@ -44,13 +48,26 @@ def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
context = ModuleContext(registry=registry, settings=settings) context = ModuleContext(registry=registry, settings=settings)
configure_runtime(context) configure_runtime(context)
registry.configure_capability_context(context) registry.configure_capability_context(context)
return registry
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
registry = _platform_registry()
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS) capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
if not isinstance(capability, CampaignDeliveryTaskProvider): if not isinstance(capability, CampaignDeliveryTaskProvider):
raise RuntimeError("Campaign delivery task capability is invalid") raise RuntimeError("Campaign delivery task capability is invalid")
return capability return capability
@celery.task(name="multimailer.send_email", bind=True, max_retries=0) def _notification_dispatch() -> NotificationDispatchProvider:
registry = _platform_registry()
capability = registry.require_capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
if not isinstance(capability, NotificationDispatchProvider):
raise RuntimeError("Notification dispatch capability is invalid")
return capability
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str): def send_email(self, job_id: str):
"""Send one explicitly queued campaign job. """Send one explicitly queued campaign job.
@@ -65,7 +82,7 @@ def send_email(self, job_id: str):
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True)) return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
@celery.task(name="multimailer.append_sent", bind=True, max_retries=None) @celery.task(name="govoplan.campaigns.append_sent", bind=True, max_retries=None)
def append_sent(self, job_id: str): def append_sent(self, job_id: str):
"""Append the exact sent MIME to the configured IMAP Sent folder.""" """Append the exact sent MIME to the configured IMAP Sent folder."""
@@ -78,3 +95,19 @@ def append_sent(self, job_id: str):
if getattr(exc, "temporary", None) is True: if getattr(exc, "temporary", None) is True:
raise self.retry(exc=exc, countdown=300) raise self.retry(exc=exc, countdown=300)
raise raise
@celery.task(name="govoplan.notifications.deliver", bind=True, max_retries=0)
def deliver_notification(self, notification_id: str):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
return dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
@celery.task(name="govoplan.notifications.deliver_pending", bind=True, max_retries=0)
def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int = 50):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
return dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))

View File

@@ -5,7 +5,6 @@ import json
from pathlib import Path from pathlib import Path
import sys import sys
import time import time
from typing import Any
from govoplan_core.core.module_installer import ( from govoplan_core.core.module_installer import (
ModuleInstallerError, ModuleInstallerError,
@@ -27,6 +26,13 @@ from govoplan_core.core.module_installer import (
update_module_installer_request, update_module_installer_request,
update_module_installer_daemon_status, update_module_installer_daemon_status,
) )
from govoplan_core.core.module_installer_notifications import (
build_runtime_notification_registry,
emit_module_installer_notification,
installer_notification_body,
installer_notification_priority,
installer_notification_subject,
)
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
from govoplan_core.core.module_management import ( from govoplan_core.core.module_management import (
@@ -39,7 +45,7 @@ from govoplan_core.server.registry import available_module_manifests
from govoplan_core.settings import settings from govoplan_core.settings import settings
def main() -> int: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.") parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.") parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.") parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
@@ -95,11 +101,43 @@ def main() -> int:
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.") parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.") parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.") parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
args = parser.parse_args() return parser
def main() -> int:
args = _build_parser().parse_args()
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url) runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
try: try:
return _dispatch_command(args=args, runtime_dir=runtime_dir)
except ModuleInstallerError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
for handler in (
_handle_license_command,
_handle_catalog_command,
_handle_runtime_command,
_handle_queue_command,
_handle_run_history_command,
):
result = handler(args=args, runtime_dir=runtime_dir)
if result is not None:
return result
return _handle_install_command(args=args, runtime_dir=runtime_dir)
def _handle_license_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
del runtime_dir
if args.issue_license: if args.issue_license:
return _issue_license_command(args)
if args.validate_license is not None:
return _validate_license_command(args)
return None
def _issue_license_command(args: argparse.Namespace) -> int:
if not args.license_id or not args.license_subject or not args.license_valid_until: if not args.license_id or not args.license_subject or not args.license_valid_until:
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.") raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
if not args.license_signing_key_id or not args.license_signing_private_key: if not args.license_signing_key_id or not args.license_signing_private_key:
@@ -134,7 +172,9 @@ def main() -> int:
output_format=args.format, output_format=args.format,
) )
return 0 return 0
if args.validate_license is not None:
def _validate_license_command(args: argparse.Namespace) -> int:
path = Path(args.validate_license).expanduser() if args.validate_license else None path = Path(args.validate_license).expanduser() if args.validate_license else None
result = module_license_diagnostics( result = module_license_diagnostics(
path, path,
@@ -144,7 +184,18 @@ def main() -> int:
) )
_print_result(result, output_format=args.format) _print_result(result, output_format=args.format)
return 0 if result.get("valid") and not result.get("missing_features") else 1 return 0 if result.get("valid") and not result.get("missing_features") else 1
def _handle_catalog_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
del runtime_dir
if args.sign_package_catalog: if args.sign_package_catalog:
return _sign_package_catalog_command(args)
if args.validate_package_catalog is not None:
return _validate_package_catalog_command(args)
return None
def _sign_package_catalog_command(args: argparse.Namespace) -> int:
if not args.catalog_signing_key_id or not args.catalog_signing_private_key: if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.") raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
path = sign_module_package_catalog( path = sign_module_package_catalog(
@@ -155,7 +206,9 @@ def main() -> int:
) )
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format) _print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
return 0 return 0
if args.validate_package_catalog is not None:
def _validate_package_catalog_command(args: argparse.Namespace) -> int:
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
result = validate_module_package_catalog( result = validate_module_package_catalog(
path, path,
@@ -165,11 +218,18 @@ def main() -> int:
) )
_print_result(result, output_format=args.format) _print_result(result, output_format=args.format)
return 0 if result.get("valid") else 1 return 0 if result.get("valid") else 1
def _handle_runtime_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.daemon_status: if args.daemon_status:
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format) _print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
return 0 return 0
if args.daemon or args.daemon_once: if args.daemon or args.daemon_once:
return _run_daemon(args=args, runtime_dir=runtime_dir) return _run_daemon(args=args, runtime_dir=runtime_dir)
return None
def _handle_queue_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.list_requests: if args.list_requests:
_print_result({ _print_result({
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)), "requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
@@ -186,13 +246,13 @@ def main() -> int:
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format) _print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
return 0 return 0
if args.enqueue_supervised: if args.enqueue_supervised:
request = queue_module_installer_request( request = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="cli", options=_request_options_from_args(args))
runtime_dir=runtime_dir,
requested_by="cli",
options=_request_options_from_args(args),
)
_print_result(request, output_format=args.format) _print_result(request, output_format=args.format)
return 0 return 0
return None
def _handle_run_history_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.list_runs: if args.list_runs:
_print_result({ _print_result({
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)), "runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
@@ -206,6 +266,11 @@ def main() -> int:
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format) _print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
return 0 return 0
if args.rollback: if args.rollback:
return _rollback_command(args=args, runtime_dir=runtime_dir)
return None
def _rollback_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
result = rollback_module_install_run( result = rollback_module_install_run(
run_id=args.rollback, run_id=args.rollback,
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
@@ -217,6 +282,8 @@ def main() -> int:
_print_result(result.as_dict(), output_format=args.format) _print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1 return 0 if result.return_code == 0 else 1
def _handle_install_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
configure_database(str(args.database_url)) configure_database(str(args.database_url))
available = available_module_manifests(ignore_load_errors=True) available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session: with get_database().session() as session:
@@ -224,6 +291,21 @@ def main() -> int:
desired = saved_desired_enabled_modules(session, configured) desired = saved_desired_enabled_modules(session, configured)
plan = saved_module_install_plan(session) plan = saved_module_install_plan(session)
if args.supervise: if args.supervise:
return _supervise_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
if args.apply or args.dry_run:
return _apply_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
return _preflight_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
def _supervise_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
result = supervise_module_install_plan( result = supervise_module_install_plan(
session=session, session=session,
plan=plan, plan=plan,
@@ -248,7 +330,17 @@ def main() -> int:
) )
_print_result(result.as_dict(), output_format=args.format) _print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1 return 0 if result.return_code == 0 else 1
if args.apply or args.dry_run:
def _apply_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
result = run_module_install_plan( result = run_module_install_plan(
session=session, session=session,
plan=plan, plan=plan,
@@ -271,6 +363,16 @@ def main() -> int:
_print_result(result.as_dict(), output_format=args.format) _print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1 return 0 if result.return_code == 0 else 1
def _preflight_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
from govoplan_core.core.maintenance import saved_maintenance_mode from govoplan_core.core.maintenance import saved_maintenance_mode
maintenance = saved_maintenance_mode(session) maintenance = saved_maintenance_mode(session)
@@ -286,9 +388,6 @@ def main() -> int:
) )
_print_preflight(preflight.as_dict(), output_format=args.format) _print_preflight(preflight.as_dict(), output_format=args.format)
return 0 if preflight.allowed else 1 return 0 if preflight.allowed else 1
except ModuleInstallerError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
def _default_webui_root() -> Path: def _default_webui_root() -> Path:
@@ -355,6 +454,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
request_id = str(request["request_id"]) request_id = str(request["request_id"])
options = request.get("options") if isinstance(request.get("options"), dict) else {} options = request.get("options") if isinstance(request.get("options"), dict) else {}
configure_database(str(args.database_url)) configure_database(str(args.database_url))
_emit_installer_daemon_notification(request, event_kind="module_installer.request.running")
try: try:
available = available_module_manifests(ignore_load_errors=True) available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session: with get_database().session() as session:
@@ -384,7 +484,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds), health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
request_context=_request_context(request), request_context=_request_context(request),
) )
update_module_installer_request( updated_request = update_module_installer_request(
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
request_id=request_id, request_id=request_id,
patch={ patch={
@@ -393,8 +493,12 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"result": result.as_dict(), "result": result.as_dict(),
}, },
) )
_emit_installer_daemon_notification(
updated_request,
event_kind="module_installer.request.completed" if result.return_code == 0 else "module_installer.request.failed",
)
except Exception as exc: except Exception as exc:
update_module_installer_request( updated_request = update_module_installer_request(
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
request_id=request_id, request_id=request_id,
patch={ patch={
@@ -403,6 +507,34 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"error": str(exc), "error": str(exc),
}, },
) )
_emit_installer_daemon_notification(updated_request, event_kind="module_installer.request.failed")
def _emit_installer_daemon_notification(request: dict[str, object], *, event_kind: str) -> None:
tenant_id = str(request.get("tenant_id") or "")
if not tenant_id:
return
registry = build_runtime_notification_registry(settings)
if registry is None:
return
status_value = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
try:
with get_database().SessionLocal() as session:
emitted = emit_module_installer_notification(
session=session,
registry=registry,
tenant_id=tenant_id,
request=request,
event_kind=event_kind,
subject=installer_notification_subject(event_kind, request),
body_text=installer_notification_body(event_kind, request),
recipient_id=str(request.get("requested_by") or "") or None,
priority=installer_notification_priority(status_value),
)
if emitted:
session.commit()
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
return
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]: def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
@@ -427,6 +559,7 @@ def _request_context(request: dict[str, object]) -> dict[str, object]:
context: dict[str, object] = { context: dict[str, object] = {
"request_id": request.get("request_id"), "request_id": request.get("request_id"),
"requested_by": request.get("requested_by"), "requested_by": request.get("requested_by"),
"tenant_id": request.get("tenant_id"),
} }
if request.get("retry_of"): if request.get("retry_of"):
context["retry_of"] = request["retry_of"] context["retry_of"] = request["retry_of"]

View File

@@ -8,8 +8,6 @@ from pathlib import Path
import json import json
import os import os
from typing import Any, Literal, Protocol, runtime_checkable from typing import Any, Literal, Protocol, runtime_checkable
import urllib.error
import urllib.request
from govoplan_core.core.module_package_catalog import ( from govoplan_core.core.module_package_catalog import (
_canonical_catalog_bytes, _canonical_catalog_bytes,
@@ -23,6 +21,7 @@ from govoplan_core.core.module_package_catalog import (
_load_private_key, _load_private_key,
_parse_trusted_keys, _parse_trusted_keys,
) )
from govoplan_core.security.http_fetch import fetch_http_text
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider" CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
@@ -31,6 +30,20 @@ DiagnosticSeverity = Literal["blocker", "warning", "info"]
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"] PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
@dataclass(frozen=True, slots=True)
class _ConfigurationCatalogValidationState:
packages: tuple[dict[str, object], ...]
channel: str | None
sequence: int | None
generated_at: str | None
not_before: str | None
expires_at: str | None
signature_state: dict[str, object]
freshness: dict[str, object]
replay: dict[str, object]
read_state: dict[str, object]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ConfigurationModuleRequirement: class ConfigurationModuleRequirement:
module_id: str module_id: str
@@ -461,55 +474,148 @@ def validate_configuration_package_catalog(
configured = source is not None and _catalog_source_exists(source) configured = source is not None and _catalog_source_exists(source)
if source is not None and not _catalog_source_exists(source): if source is not None and not _catalog_source_exists(source):
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}") return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() else None} read_state = _configuration_catalog_default_read_state()
try: try:
payload, read_state = _read_catalog_payload_with_metadata(source)
packages = _normalize_catalog_packages(payload)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
generated_at = _catalog_optional_text(payload, "generated_at")
not_before = _catalog_optional_text(payload, "not_before")
expires_at = _catalog_optional_text(payload, "expires_at")
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys() effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys) state = _configuration_catalog_validation_state(source, trusted_keys=effective_trusted_keys)
freshness = _catalog_freshness_state(payload) read_state = state.read_state
replay = _catalog_replay_state(channel=channel, sequence=sequence)
except Exception as exc: except Exception as exc:
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc)) return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
if signature_state.get("fatal"): policy_error = _configuration_catalog_policy_error(
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"])) source,
if effective_approved_channels and channel not in effective_approved_channels: configured=configured,
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=f"Configuration package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.") state=state,
if effective_require_trusted and not signature_state["trusted"]: require_trusted=effective_require_trusted,
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"] or "Configuration package catalog must be signed by a trusted key.")) approved_channels=effective_approved_channels,
if not freshness["valid"]: )
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(freshness["error"])) if policy_error is not None:
if not replay["valid"]: return policy_error
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(replay["error"]))
warnings = [str(item) for item in freshness.get("warnings", ()) if item]
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
if not signature_state["signed"]:
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not signature_state["trusted"]:
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return _validation_result( return _validation_result(
source, source,
valid=True, valid=True,
configured=configured, configured=configured,
read_state=read_state, read_state=state.read_state,
packages=packages, packages=state.packages,
channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
warnings=_configuration_catalog_warnings(state),
)
def _configuration_catalog_validation_state(
source: Path | str | None,
*,
trusted_keys: dict[str, str],
) -> _ConfigurationCatalogValidationState:
payload, read_state = _read_catalog_payload_with_metadata(source)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
return _ConfigurationCatalogValidationState(
packages=_normalize_catalog_packages(payload),
channel=channel, channel=channel,
sequence=sequence, sequence=sequence,
generated_at=generated_at, generated_at=_catalog_optional_text(payload, "generated_at"),
not_before=not_before, not_before=_catalog_optional_text(payload, "not_before"),
expires_at=expires_at, expires_at=_catalog_optional_text(payload, "expires_at"),
signature_state=signature_state, signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
warnings=warnings, freshness=_catalog_freshness_state(payload),
replay=_catalog_replay_state(channel=channel, sequence=sequence),
read_state=read_state,
) )
def _configuration_catalog_policy_error(
source: Path | str | None,
*,
configured: bool,
state: _ConfigurationCatalogValidationState,
require_trusted: bool,
approved_channels: tuple[str, ...],
) -> dict[str, object] | None:
if state.signature_state.get("fatal"):
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.signature_state["error"]),
)
if approved_channels and state.channel not in approved_channels:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=(
f"Configuration package catalog channel {state.channel!r} is not approved. "
f"Approved channels: {', '.join(approved_channels)}."
),
)
if require_trusted and not state.signature_state["trusted"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.signature_state["error"] or "Configuration package catalog must be signed by a trusted key."),
)
if not state.freshness["valid"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.freshness["error"]),
)
if not state.replay["valid"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.replay["error"]),
)
return None
def _configuration_catalog_invalid_result(
source: Path | str | None,
*,
configured: bool,
state: _ConfigurationCatalogValidationState,
error: str,
) -> dict[str, object]:
return _validation_result(
source,
configured=configured,
read_state=state.read_state,
packages=(),
channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
error=error,
)
def _configuration_catalog_warnings(state: _ConfigurationCatalogValidationState) -> list[str]:
warnings = [str(item) for item in state.freshness.get("warnings", ()) if item]
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
if not state.signature_state["signed"]:
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not state.signature_state["trusted"]:
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return warnings
def _configuration_catalog_default_read_state() -> dict[str, object]:
cache_path = _configured_catalog_cache_path()
return {"cache_used": False, "cache_path": str(cache_path) if cache_path else None}
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path: def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
payload = _read_catalog_payload(path) payload = _read_catalog_payload(path)
if not isinstance(payload, dict): if not isinstance(payload, dict):
@@ -686,17 +792,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str: def _read_trusted_keys_url(url: str) -> str:
if not _is_http_url(url):
raise ValueError("Trusted configuration catalog key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated configuration key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected body = fetch_http_text(url, timeout=15, label="Trusted configuration catalog key URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body return body
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8") return cache_path.read_text(encoding="utf-8")
raise raise
@@ -714,13 +817,12 @@ def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[obje
return {"packages": []}, metadata return {"packages": []}, metadata
if isinstance(source, str) and _is_http_url(source): if isinstance(source, str) and _is_http_url(source):
try: try:
with urllib.request.urlopen(source, timeout=15) as response: # noqa: S310 - validated configuration catalog HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected body = fetch_http_text(source, timeout=15, label="Configuration package catalog URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return json.loads(body), metadata return json.loads(body), metadata
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
metadata["cache_used"] = True metadata["cache_used"] = True
return json.loads(cache_path.read_text(encoding="utf-8")), metadata return json.loads(cache_path.read_text(encoding="utf-8")), metadata

View File

@@ -97,6 +97,16 @@ class ConfigurationChangeSafetyPlan:
} }
@dataclass(frozen=True, slots=True)
class _ConfigurationChangeSafetyState:
field: ConfigurationFieldSafety
missing_scopes: tuple[str, ...]
blockers: tuple[str, ...]
warnings: tuple[str, ...]
approval_required: bool
approval_satisfied: bool
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = ( _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
ConfigurationFieldSafety( ConfigurationFieldSafety(
key="module_management.desired_enabled", key="module_management.desired_enabled",
@@ -339,6 +349,26 @@ def plan_configuration_change(
) -> ConfigurationChangeSafetyPlan: ) -> ConfigurationChangeSafetyPlan:
field = classify_configuration_field(key) field = classify_configuration_field(key)
if field is None: if field is None:
return _unknown_configuration_plan(key)
if not include_env_only and not field.ui_managed:
return _deployment_managed_configuration_plan(key, field)
state = _configuration_change_safety_state(
field,
actor_scopes=actor_scopes,
value=value,
dry_run=dry_run,
maintenance_mode=maintenance_mode,
approval_count=approval_count,
)
return _configuration_change_safety_plan(
field,
state=state,
dry_run=dry_run,
maintenance_mode=maintenance_mode,
)
def _unknown_configuration_plan(key: str) -> ConfigurationChangeSafetyPlan:
return ConfigurationChangeSafetyPlan( return ConfigurationChangeSafetyPlan(
key=key, key=key,
allowed=False, allowed=False,
@@ -346,7 +376,9 @@ def plan_configuration_change(
blockers=("unknown_configuration_field",), blockers=("unknown_configuration_field",),
policy_explanation="This setting is not in the configuration safety catalog.", policy_explanation="This setting is not in the configuration safety catalog.",
) )
if not include_env_only and not field.ui_managed:
def _deployment_managed_configuration_plan(key: str, field: ConfigurationFieldSafety) -> ConfigurationChangeSafetyPlan:
return ConfigurationChangeSafetyPlan( return ConfigurationChangeSafetyPlan(
key=key, key=key,
allowed=False, allowed=False,
@@ -356,6 +388,17 @@ def plan_configuration_change(
blockers=("deployment_managed",), blockers=("deployment_managed",),
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.", policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
) )
def _configuration_change_safety_state(
field: ConfigurationFieldSafety,
*,
actor_scopes: tuple[str, ...] | list[str],
value: object,
dry_run: bool,
maintenance_mode: bool,
approval_count: int,
) -> _ConfigurationChangeSafetyState:
blockers: list[str] = [] blockers: list[str] = []
warnings: list[str] = [] warnings: list[str] = []
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope)) missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
@@ -377,24 +420,41 @@ def plan_configuration_change(
blockers.append("env_only_secret") blockers.append("env_only_secret")
if field.rollback_history_required: if field.rollback_history_required:
warnings.append("rollback_history_required") warnings.append("rollback_history_required")
return ConfigurationChangeSafetyPlan( return _ConfigurationChangeSafetyState(
key=field.key,
allowed=not blockers,
field=field, field=field,
risk=field.risk,
missing_scopes=missing_scopes, missing_scopes=missing_scopes,
dry_run_required=field.dry_run_required, blockers=tuple(dict.fromkeys(blockers)),
dry_run_satisfied=not field.dry_run_required or dry_run, warnings=tuple(dict.fromkeys(warnings)),
approval_required=approval_required, approval_required=approval_required,
approval_satisfied=approval_satisfied, approval_satisfied=approval_satisfied,
)
def _configuration_change_safety_plan(
field: ConfigurationFieldSafety,
*,
state: _ConfigurationChangeSafetyState,
dry_run: bool,
maintenance_mode: bool,
) -> ConfigurationChangeSafetyPlan:
return ConfigurationChangeSafetyPlan(
key=field.key,
allowed=not state.blockers,
field=field,
risk=field.risk,
missing_scopes=state.missing_scopes,
dry_run_required=field.dry_run_required,
dry_run_satisfied=not field.dry_run_required or dry_run,
approval_required=state.approval_required,
approval_satisfied=state.approval_satisfied,
maintenance_required=field.maintenance_required, maintenance_required=field.maintenance_required,
maintenance_satisfied=not field.maintenance_required or maintenance_mode, maintenance_satisfied=not field.maintenance_required or maintenance_mode,
rollback_history_required=field.rollback_history_required, rollback_history_required=field.rollback_history_required,
secret_handling=field.secret_handling, secret_handling=field.secret_handling,
audit_event=field.audit_event, audit_event=field.audit_event,
policy_explanation=_policy_explanation(field), policy_explanation=_policy_explanation(field),
blockers=tuple(dict.fromkeys(blockers)), blockers=state.blockers,
warnings=tuple(dict.fromkeys(warnings)), warnings=state.warnings,
) )

View File

@@ -72,6 +72,25 @@ class ConfigValidationResult:
return "\n".join(lines) return "\n".join(lines)
@dataclass(frozen=True, slots=True)
class _RuntimeProfile:
name: str
production: bool
production_like: bool
local: bool
@dataclass(slots=True)
class _ConfigIssueCollector:
strict: bool
issues: list[ConfigIssue]
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
if self.strict and level == "warning":
level = "error"
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"} _LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"} _PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"} _PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
@@ -114,95 +133,130 @@ def validate_runtime_configuration(
strict: bool = False, strict: bool = False,
) -> ConfigValidationResult: ) -> ConfigValidationResult:
env = dict(os.environ if environ is None else environ) env = dict(os.environ if environ is None else environ)
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV")) runtime = _runtime_profile(env, profile=profile)
issues: list[ConfigIssue] = [] collector = _ConfigIssueCollector(strict=strict, issues=[])
_validate_app_env(env, runtime, collector)
_validate_database_settings(env, runtime, collector)
_validate_master_key(env, runtime, collector)
_validate_enabled_modules(env, runtime, collector)
_validate_async_and_auth_settings(env, runtime, collector)
_validate_cors_settings(env, runtime, collector)
_validate_file_storage_settings(env, runtime, collector)
_validate_module_catalog_trust(env, runtime, collector)
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"} production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
local = clean_profile in _LOCAL_PROFILES and not production_like return _RuntimeProfile(
name=clean_profile,
production=production,
production_like=production_like,
local=clean_profile in _LOCAL_PROFILES and not production_like,
)
def issue(level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
if strict and level == "warning":
level = "error"
issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
app_env = _clean(env.get("APP_ENV")) app_env = _clean(env.get("APP_ENV"))
if not app_env and production_like: if not app_env and runtime.production_like:
issue("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.") collector.add("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
elif app_env.lower() in {"dev", "test", "local"} and production_like: elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
issue("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {clean_profile!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.") collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
database_url = _clean(env.get("DATABASE_URL")) database_url = _clean(env.get("DATABASE_URL"))
if not database_url: if not database_url:
issue("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.") collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
else: return
backend = _database_backend(database_url) backend = _database_backend(database_url)
if backend is None: if backend is None:
issue("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.") collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
elif backend == "sqlite" and production_like: return
issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.") if backend == "sqlite" and runtime.production_like:
elif backend != "postgresql" and production: collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
issue("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.") elif backend != "postgresql" and runtime.production:
collector.add("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")): if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
issue("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.") collector.add("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
def _validate_master_key(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
master_key = _clean(env.get("MASTER_KEY_B64")) master_key = _clean(env.get("MASTER_KEY_B64"))
if not master_key and not local: if not master_key and not runtime.local:
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.") collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
elif master_key: return
if not master_key:
return
error = _master_key_error(master_key) error = _master_key_error(master_key)
if error: if error:
issue("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.") collector.add("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
elif "change-me" in master_key.lower() or "generate" in master_key.lower(): elif "change-me" in master_key.lower() or "generate" in master_key.lower():
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.") collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
def _validate_enabled_modules(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
enabled_modules = _csv(env.get("ENABLED_MODULES")) enabled_modules = _csv(env.get("ENABLED_MODULES"))
if not enabled_modules and production_like: if not enabled_modules and runtime.production_like:
issue("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.") collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
elif "access" not in enabled_modules and production_like: elif "access" not in enabled_modules and runtime.production_like:
issue("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.") collector.add("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
elif enabled_modules and "admin" not in enabled_modules and production_like: elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
issue("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.") collector.add("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
celery_enabled = _truthy(env.get("CELERY_ENABLED"))
if celery_enabled and not _clean(env.get("REDIS_URL")):
issue("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
if production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")): def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
issue("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.") if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
collector.add("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
collector.add("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
collector.add("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
if production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
issue("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
cors_origins = _csv(env.get("CORS_ORIGINS")) cors_origins = _csv(env.get("CORS_ORIGINS"))
if production_like and not cors_origins: if runtime.production_like and not cors_origins:
issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.") collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
elif "*" in cors_origins and production_like: elif "*" in cors_origins and runtime.production_like:
issue("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.") collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
elif production and set(cors_origins) <= _DEFAULT_LOCAL_CORS: elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
issue("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.") collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local" storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
if storage_backend == "local": if storage_backend == "local":
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like: _validate_local_file_storage(env, runtime, collector)
issue("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
elif production:
issue("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
elif storage_backend == "s3": elif storage_backend == "s3":
_validate_s3_file_storage(env, collector)
else:
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
collector.add("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
elif runtime.production:
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"): for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
if not _clean(env.get(key)): if not _clean(env.get(key)):
issue("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.") collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
else:
issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG")) catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
if production and catalog_source: if not runtime.production or not catalog_source:
return
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")): if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.") collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")): if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.") collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
return ConfigValidationResult(profile=clean_profile, issues=tuple(issues))
def _self_hosted_env_template(master_key: str) -> str: def _self_hosted_env_template(master_key: str) -> str:
@@ -299,8 +353,8 @@ def _master_key_error(value: str) -> str | None:
try: try:
Fernet(candidate) Fernet(candidate)
return None return None
except Exception: except (TypeError, ValueError):
pass raw = None
try: try:
raw = base64.b64decode(candidate) raw = base64.b64decode(candidate)
except Exception: except Exception:
@@ -309,6 +363,6 @@ def _master_key_error(value: str) -> str | None:
return "MASTER_KEY_B64 must decode to exactly 32 bytes." return "MASTER_KEY_B64 must decode to exactly 32 bytes."
try: try:
Fernet(base64.urlsafe_b64encode(raw)) Fernet(base64.urlsafe_b64encode(raw))
except Exception: except (TypeError, ValueError):
return "MASTER_KEY_B64 is not usable as a Fernet key." return "MASTER_KEY_B64 is not usable as a Fernet key."
return None return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
INSTALLER_NOTIFICATION_ACTION_URL = "/admin?section=modules"
def emit_module_installer_notification(
*,
session: object,
registry: object | None,
tenant_id: str | None,
request: Mapping[str, object],
event_kind: str,
subject: str,
body_text: str,
recipient_id: str | None = None,
priority: int = 5,
) -> bool:
if not tenant_id:
return False
provider = notification_dispatch_provider(registry)
if provider is None:
return False
request_id = str(request.get("request_id") or "")
if not request_id:
return False
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=tenant_id,
source_module="core",
source_resource_type="module_install_request",
source_resource_id=request_id,
event_kind=event_kind,
channel="inbox",
recipient_type="user" if recipient_id else None,
recipient_id=recipient_id,
subject=subject,
body_text=body_text,
action_url=INSTALLER_NOTIFICATION_ACTION_URL,
priority=priority,
payload={
"request_id": request_id,
"status": request.get("status"),
"run_id": _result_run_id(request),
},
metadata={
"trace": request.get("trace") if isinstance(request.get("trace"), Mapping) else None,
"retry_of": request.get("retry_of"),
},
),
enqueue_delivery=False,
)
return True
def build_runtime_notification_registry(settings: object) -> object | None:
try:
raw_enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""))
candidate_modules = startup_candidate_module_ids(str(getattr(settings, "enabled_modules", "") or ""), raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""), available=available_modules)
if "notifications" not in enabled_modules:
return None
registry = build_platform_registry(enabled_modules)
registry.configure_capability_context(ModuleContext(registry=registry, settings=settings))
return registry
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
return None
def installer_notification_subject(event_kind: str, request: Mapping[str, object]) -> str:
request_id = str(request.get("request_id") or "unknown")
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
prefixes = {
"queued": "Module installer request queued",
"running": "Module installer request started",
"completed": "Module installer request completed",
"failed": "Module installer request failed",
"cancelled": "Module installer request cancelled",
}
prefix = prefixes.get(status, "Module installer request updated")
return ": ".join((prefix, request_id))
def _installer_status_sentence(request_id: str, status: str) -> str:
return " ".join(("Installer request", request_id, "is", status))
def installer_notification_body(event_kind: str, request: Mapping[str, object]) -> str:
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
request_id = str(request.get("request_id") or "unknown")
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
error = str(request.get("error") or result.get("error") or "").strip()
sentence = _installer_status_sentence(request_id, status)
if error:
return ". Error: ".join((sentence, error))
run_id = _result_run_id(request)
if run_id:
return ". Run: ".join((sentence, run_id))
return sentence + "."
def installer_notification_priority(status: str) -> int:
if status == "failed":
return 20
if status in {"completed", "cancelled"}:
return 8
if status == "running":
return 4
return 5
def _result_run_id(request: Mapping[str, object]) -> str | None:
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
run_id: Any = result.get("run_id") if isinstance(result, Mapping) else None
return str(run_id) if run_id else None

View File

@@ -7,13 +7,11 @@ import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import urllib.error
import urllib.parse
import urllib.request
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from govoplan_core.security.http_fetch import fetch_http_text
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]: def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
@@ -258,18 +256,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str: def _read_trusted_keys_url(url: str) -> str:
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password:
raise ValueError("Trusted license key URL must be an absolute HTTP(S) URL without embedded credentials.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated license key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected body = fetch_http_text(url, timeout=15, label="Trusted license key URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body return body
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8") return cache_path.read_text(encoding="utf-8")
raise raise

View File

@@ -73,6 +73,23 @@ class ModuleInstallPlanItem:
return payload return payload
@dataclass(frozen=True, slots=True)
class _NormalizedModuleInstallPlanItem:
module_id: str
action: str
source: str
catalog: Mapping[str, object] | None
status: str
python_package: str | None
python_ref: str | None
webui_package: str | None
webui_ref: str | None
artifact_integrity: Mapping[str, object] | None
data_safety_acknowledged: bool
destroy_data: bool
notes: str | None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ModuleInstallPlan: class ModuleInstallPlan:
items: tuple[ModuleInstallPlanItem, ...] = () items: tuple[ModuleInstallPlanItem, ...] = ()
@@ -291,6 +308,28 @@ def desired_modules_after_package_plan(
def normalize_module_install_plan_item( def normalize_module_install_plan_item(
item: Mapping[str, object] | ModuleInstallPlanItem, item: Mapping[str, object] | ModuleInstallPlanItem,
) -> ModuleInstallPlanItem: ) -> ModuleInstallPlanItem:
normalized = _normalized_module_install_plan_item(item)
_validate_module_install_plan_item(normalized)
return ModuleInstallPlanItem(
module_id=normalized.module_id,
action=normalized.action,
source=normalized.source,
catalog=normalized.catalog,
python_package=normalized.python_package,
python_ref=normalized.python_ref,
webui_package=normalized.webui_package,
webui_ref=normalized.webui_ref,
artifact_integrity=normalized.artifact_integrity,
data_safety_acknowledged=normalized.data_safety_acknowledged,
destroy_data=normalized.destroy_data,
status=normalized.status,
notes=normalized.notes,
)
def _normalized_module_install_plan_item(
item: Mapping[str, object] | ModuleInstallPlanItem,
) -> _NormalizedModuleInstallPlanItem:
if isinstance(item, ModuleInstallPlanItem): if isinstance(item, ModuleInstallPlanItem):
raw = item.as_dict() raw = item.as_dict()
elif isinstance(item, Mapping): elif isinstance(item, Mapping):
@@ -311,33 +350,12 @@ def normalize_module_install_plan_item(
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged")) data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
destroy_data = _clean_bool(raw.get("destroy_data")) destroy_data = _clean_bool(raw.get("destroy_data"))
notes = _clean_optional_string(raw.get("notes")) notes = _clean_optional_string(raw.get("notes"))
return _NormalizedModuleInstallPlanItem(
if action not in INSTALL_PLAN_ACTIONS:
raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
if status not in INSTALL_PLAN_STATUSES:
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
if source not in INSTALL_PLAN_SOURCES:
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
if action in {"install", "update"} and not python_ref:
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
if action == "uninstall" and not python_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
if action != "uninstall" and destroy_data:
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
if action == "uninstall" and webui_ref and not webui_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
if python_ref:
_validate_dependency_ref(python_ref, field="python_ref", module_id=module_id)
if webui_ref:
_validate_dependency_ref(webui_ref, field="webui_ref", module_id=module_id)
return ModuleInstallPlanItem(
module_id=module_id, module_id=module_id,
action=action, action=action,
source=source, source=source,
catalog=catalog, catalog=catalog,
status=status,
python_package=python_package, python_package=python_package,
python_ref=python_ref, python_ref=python_ref,
webui_package=webui_package, webui_package=webui_package,
@@ -345,11 +363,41 @@ def normalize_module_install_plan_item(
artifact_integrity=artifact_integrity, artifact_integrity=artifact_integrity,
data_safety_acknowledged=data_safety_acknowledged, data_safety_acknowledged=data_safety_acknowledged,
destroy_data=destroy_data, destroy_data=destroy_data,
status=status,
notes=notes, notes=notes,
) )
def _validate_module_install_plan_item(item: _NormalizedModuleInstallPlanItem) -> None:
if item.action not in INSTALL_PLAN_ACTIONS:
raise ModuleManagementError(f"Unsupported install plan action for {item.module_id!r}: {item.action!r}.")
if item.status not in INSTALL_PLAN_STATUSES:
raise ModuleManagementError(f"Unsupported install plan status for {item.module_id!r}: {item.status!r}.")
if item.source not in INSTALL_PLAN_SOURCES:
raise ModuleManagementError(f"Unsupported install plan source for {item.module_id!r}: {item.source!r}.")
_validate_module_install_plan_item_requirements(item)
_validate_module_install_plan_item_refs(item)
def _validate_module_install_plan_item_requirements(item: _NormalizedModuleInstallPlanItem) -> None:
if item.action in {"install", "update"} and not item.python_ref:
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs a Python package reference.")
if item.action == "uninstall" and not item.python_package:
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} needs a Python package name.")
if item.action != "uninstall" and item.destroy_data:
raise ModuleManagementError(f"Install plan item {item.module_id!r} can only destroy data during uninstall.")
if item.action in {"install", "update"} and bool(item.webui_package) != bool(item.webui_ref):
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs both WebUI package and WebUI reference, or neither.")
if item.action == "uninstall" and item.webui_ref and not item.webui_package:
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} has a WebUI reference but no WebUI package.")
def _validate_module_install_plan_item_refs(item: _NormalizedModuleInstallPlanItem) -> None:
if item.python_ref:
_validate_dependency_ref(item.python_ref, field="python_ref", module_id=item.module_id)
if item.webui_ref:
_validate_dependency_ref(item.webui_ref, field="webui_ref", module_id=item.module_id)
def plan_desired_enabled_modules( def plan_desired_enabled_modules(
requested_enabled: Iterable[str], requested_enabled: Iterable[str],
available: Mapping[str, ModuleManifest], available: Mapping[str, ModuleManifest],

View File

@@ -3,20 +3,20 @@ from __future__ import annotations
import base64 import base64
import binascii import binascii
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
import json import json
import os import os
import re import re
from typing import Any from typing import Any
import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") _INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive") CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
@@ -28,6 +28,20 @@ CATALOG_MIGRATION_TASK_PHASES = (
) )
@dataclass(frozen=True, slots=True)
class _CatalogValidationState:
modules: tuple[dict[str, object], ...]
channel: str | None
sequence: int | None
generated_at: str | None
not_before: str | None
expires_at: str | None
signature_state: dict[str, object]
freshness: dict[str, object]
replay: dict[str, object]
read_state: dict[str, object]
def module_package_catalog( def module_package_catalog(
path: Path | str | None = None, path: Path | str | None = None,
*, *,
@@ -61,178 +75,143 @@ def validate_module_package_catalog(
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys() effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
if catalog_source is not None and not _catalog_source_exists(catalog_source): if catalog_source is not None and not _catalog_source_exists(catalog_source):
return { return _catalog_error_result(catalog_source, error=f"Module package catalog does not exist: {catalog_source}")
"valid": False,
"configured": True,
"path": str(catalog_source),
"source": str(catalog_source),
"source_type": _catalog_source_type(catalog_source),
"cache_used": False,
"cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None,
"modules": [],
"channel": None,
"sequence": None,
"generated_at": None,
"not_before": None,
"expires_at": None,
"signed": False,
"trusted": False,
"key_id": None,
"warnings": [],
"error": f"Module package catalog does not exist: {catalog_source}",
}
warnings: list[str] = []
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None}
try: try:
payload, read_state = _read_catalog_payload_with_metadata(catalog_source) state = _catalog_validation_state(catalog_source, trusted_keys=effective_trusted_keys)
except Exception as exc:
return _catalog_error_result(catalog_source, error=str(exc))
policy_error = _catalog_policy_error(
catalog_source,
state,
require_trusted=effective_require_trusted,
approved_channels=effective_approved_channels,
)
if policy_error is not None:
return policy_error
return _valid_catalog_result(catalog_source, state)
def _catalog_validation_state(
source: Path | str | None,
*,
trusted_keys: dict[str, str],
) -> _CatalogValidationState:
payload, read_state = _read_catalog_payload_with_metadata(source)
modules = _normalize_catalog_modules(payload) modules = _normalize_catalog_modules(payload)
channel = _catalog_channel(payload) channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload) sequence = _catalog_sequence(payload)
generated_at = _catalog_optional_text(payload, "generated_at") return _CatalogValidationState(
not_before = _catalog_optional_text(payload, "not_before") modules=modules,
expires_at = _catalog_optional_text(payload, "expires_at")
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
freshness = _catalog_freshness_state(payload)
replay = _catalog_replay_state(channel=channel, sequence=sequence)
except Exception as exc:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": None,
"sequence": None,
"generated_at": None,
"not_before": None,
"expires_at": None,
"signed": False,
"trusted": False,
"key_id": None,
"warnings": [],
"error": str(exc),
}
if signature_state.get("fatal"):
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog signature is invalid."),
}
if effective_approved_channels and channel not in effective_approved_channels:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": f"Module package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.",
}
if effective_require_trusted and not signature_state["trusted"]:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": False,
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog must be signed by a trusted key."),
}
if not freshness["valid"]:
return _invalid_catalog_result(
catalog_source,
modules=(),
channel=channel, channel=channel,
sequence=sequence, sequence=sequence,
generated_at=generated_at, generated_at=_catalog_optional_text(payload, "generated_at"),
not_before=not_before, not_before=_catalog_optional_text(payload, "not_before"),
expires_at=expires_at, expires_at=_catalog_optional_text(payload, "expires_at"),
signature_state=signature_state, signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
freshness=_catalog_freshness_state(payload),
replay=_catalog_replay_state(channel=channel, sequence=sequence),
read_state=read_state, read_state=read_state,
error=str(freshness["error"]),
) )
if not replay["valid"]:
def _catalog_policy_error(
source: Path | str | None,
state: _CatalogValidationState,
*,
require_trusted: bool,
approved_channels: tuple[str, ...],
) -> dict[str, object] | None:
if state.signature_state.get("fatal"):
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog signature is invalid."))
if approved_channels and state.channel not in approved_channels:
return _invalid_catalog_state_result(
source,
state,
error=f"Module package catalog channel {state.channel!r} is not approved. Approved channels: {', '.join(approved_channels)}.",
)
if require_trusted and not state.signature_state["trusted"]:
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog must be signed by a trusted key."))
if not state.freshness["valid"]:
return _invalid_catalog_state_result(source, state, error=str(state.freshness["error"]))
if not state.replay["valid"]:
return _invalid_catalog_state_result(source, state, error=str(state.replay["error"]))
return None
def _catalog_error_result(source: Path | str | None, *, error: str) -> dict[str, object]:
return _invalid_catalog_result( return _invalid_catalog_result(
catalog_source, source,
modules=(), modules=(),
channel=channel, channel=None,
sequence=sequence, sequence=None,
generated_at=generated_at, generated_at=None,
not_before=not_before, not_before=None,
expires_at=expires_at, expires_at=None,
signature_state=signature_state, signature_state=_unsigned_catalog_signature_state(),
read_state=read_state, read_state=_default_catalog_read_state(),
error=str(replay["error"]), error=error,
) )
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
warnings.extend(_catalog_interface_warnings(modules)) def _invalid_catalog_state_result(source: Path | str | None, state: _CatalogValidationState, *, error: str) -> dict[str, object]:
if not signature_state["signed"]: return _invalid_catalog_result(
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.") source,
elif not signature_state["trusted"]: modules=(),
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key.")) channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
read_state=state.read_state,
error=error,
)
def _valid_catalog_result(source: Path | str | None, state: _CatalogValidationState) -> dict[str, object]:
return { return {
"valid": True, "valid": True,
"configured": catalog_source is not None and _catalog_source_exists(catalog_source), "configured": source is not None and _catalog_source_exists(source),
"path": str(catalog_source) if catalog_source is not None else None, "path": str(source) if source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None, "source": str(source) if source is not None else None,
"source_type": _catalog_source_type(catalog_source), "source_type": _catalog_source_type(source),
"cache_used": bool(read_state.get("cache_used")), "cache_used": bool(state.read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None, "cache_path": state.read_state.get("cache_path") if isinstance(state.read_state.get("cache_path"), str) else None,
"modules": list(modules), "modules": list(state.modules),
"channel": channel, "channel": state.channel,
"sequence": sequence, "sequence": state.sequence,
"generated_at": generated_at, "generated_at": state.generated_at,
"not_before": not_before, "not_before": state.not_before,
"expires_at": expires_at, "expires_at": state.expires_at,
"signed": signature_state["signed"], "signed": state.signature_state["signed"],
"trusted": signature_state["trusted"], "trusted": state.signature_state["trusted"],
"key_id": signature_state["key_id"], "key_id": state.signature_state["key_id"],
"warnings": warnings, "warnings": _catalog_validation_warnings(state),
"error": None, "error": None,
} }
def _catalog_validation_warnings(state: _CatalogValidationState) -> list[str]:
warnings: list[str] = []
warnings.extend(str(item) for item in state.freshness.get("warnings", ()) if item)
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
warnings.extend(_catalog_interface_warnings(state.modules))
if not state.signature_state["signed"]:
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not state.signature_state["trusted"]:
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return warnings
def _default_catalog_read_state() -> dict[str, object]:
cache_path = _configured_catalog_cache_path()
return {"cache_used": False, "cache_path": str(cache_path) if cache_path is not None else None}
def _unsigned_catalog_signature_state() -> dict[str, object]:
return {"signed": False, "trusted": False, "key_id": None}
def sign_module_package_catalog( def sign_module_package_catalog(
*, *,
path: Path, path: Path,
@@ -350,17 +329,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str: def _read_trusted_keys_url(url: str) -> str:
if not _is_http_url(url):
raise ValueError("Trusted catalog key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated catalog key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected body = fetch_http_text(url, timeout=15, label="Trusted catalog key URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body return body
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8") return cache_path.read_text(encoding="utf-8")
raise raise
@@ -421,13 +397,12 @@ def _read_catalog_url(url: str) -> str:
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]: def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
cache_path = _configured_catalog_cache_path() cache_path = _configured_catalog_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated module catalog HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected body = fetch_http_text(url, timeout=15, label="Module package catalog URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body, False return body, False
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8"), True return cache_path.read_text(encoding="utf-8"), True
raise raise
@@ -935,8 +910,7 @@ def _catalog_source_type(source: Path | str | None) -> str | None:
def _is_http_url(value: str) -> bool: def _is_http_url(value: str) -> bool:
parsed = urllib.parse.urlparse(value) return is_http_url(value)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc) and not parsed.username and not parsed.password
def _invalid_catalog_result( def _invalid_catalog_result(

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_NOTIFICATIONS_DISPATCH = "notifications.dispatch"
@dataclass(frozen=True, slots=True)
class NotificationDispatchRequest:
tenant_id: str
source_module: str
source_resource_type: str
source_resource_id: str | None
event_kind: str
channel: str = "inbox"
recipient: str | None = None
recipient_type: str | None = None
recipient_id: str | None = None
recipient_label: str | None = None
subject: str | None = None
body_text: str | None = None
body_html: str | None = None
action_url: str | None = None
priority: int = 0
not_before_at: datetime | None = None
payload: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class NotificationDispatchProvider(Protocol):
def enqueue_notification(
self,
session: object,
request: NotificationDispatchRequest,
*,
enqueue_delivery: bool = True,
) -> Mapping[str, object]:
...
def deliver_notification(self, session: object, *, notification_id: str) -> Mapping[str, object]:
...
def deliver_pending(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def notification_dispatch_provider(registry: object | None) -> NotificationDispatchProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_NOTIFICATIONS_DISPATCH):
return None
capability = registry.capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
return capability if isinstance(capability, NotificationDispatchProvider) else None

View File

@@ -186,46 +186,20 @@ class PlatformRegistry:
def validate(self) -> RegistrySnapshot: def validate(self) -> RegistrySnapshot:
ordered = tuple(self._topologically_sorted()) ordered = tuple(self._topologically_sorted())
available_capabilities = set(self._capability_factories) available_capabilities = set(self._capability_factories)
seen_permissions: dict[str, PermissionDefinition] = {}
for manifest in ordered: for manifest in ordered:
_validate_manifest_shape(manifest) _validate_manifest_shape(manifest)
for dependency in manifest.dependencies: _validate_manifest_relationships(
if dependency not in self._manifests: manifest,
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}") known_modules=self._manifests,
for capability in manifest.required_capabilities: available_capabilities=available_capabilities,
if capability not in available_capabilities: )
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}") permissions = _collect_manifest_permissions(ordered)
for dependency in manifest.optional_dependencies:
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
for permission in manifest.permissions:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
if permission.scope in seen_permissions:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
seen_permissions[permission.scope] = permission
_validate_interface_closure(ordered) _validate_interface_closure(ordered)
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
known_scopes = set(seen_permissions)
for manifest in ordered:
for template in manifest.role_templates:
for scope in template.permissions:
if scope in {"*", "tenant:*", "system:*"}:
continue
if _WILDCARD_RE.match(scope):
continue
if scope not in known_scopes:
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
return RegistrySnapshot( return RegistrySnapshot(
manifests=ordered, manifests=ordered,
permissions=tuple(seen_permissions.values()), permissions=tuple(permissions.values()),
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates), role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
nav_items=self.nav_items(), nav_items=self.nav_items(),
) )
@@ -299,7 +273,80 @@ def _attribute_delete_veto_issue(
return replace(issue, module_id=issue.module_id or registration.module_id, details=details) return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
def _validate_manifest_relationships(
manifest: ModuleManifest,
*,
known_modules: Mapping[str, ModuleManifest],
available_capabilities: set[str],
) -> None:
for dependency in manifest.dependencies:
if dependency not in known_modules:
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
for capability in manifest.required_capabilities:
if capability not in available_capabilities:
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
for dependency in manifest.optional_dependencies:
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
def _collect_manifest_permissions(manifests: tuple[ModuleManifest, ...]) -> dict[str, PermissionDefinition]:
permissions: dict[str, PermissionDefinition] = {}
for manifest in manifests:
for permission in manifest.permissions:
_validate_manifest_permission(manifest, permission, permissions)
permissions[permission.scope] = permission
return permissions
def _validate_manifest_permission(
manifest: ModuleManifest,
permission: PermissionDefinition,
seen_permissions: Mapping[str, PermissionDefinition],
) -> None:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
if permission.scope in seen_permissions:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
def _validate_role_template_scopes(
manifests: tuple[ModuleManifest, ...],
*,
known_scopes: set[str],
) -> None:
for manifest in manifests:
for template in manifest.role_templates:
for scope in template.permissions:
if _role_template_scope_known(scope, known_scopes):
continue
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
def _role_template_scope_known(scope: str, known_scopes: set[str]) -> bool:
if scope in {"*", "tenant:*", "system:*"}:
return True
if _WILDCARD_RE.match(scope):
return True
return scope in known_scopes
def _validate_manifest_shape(manifest: ModuleManifest) -> None: def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_identity(manifest)
_validate_manifest_contract_lists(manifest)
_validate_manifest_overlaps(manifest)
_validate_manifest_migration_spec(manifest)
_validate_manifest_frontend(manifest)
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item)
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
if not _MODULE_ID_RE.match(manifest.id): if not _MODULE_ID_RE.match(manifest.id):
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}") raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
if not manifest.name.strip(): if not manifest.name.strip():
@@ -313,12 +360,17 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}" f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}"
) )
def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies) _validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies) _validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities) _validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities) _validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
_validate_interface_providers(manifest.id, manifest.provides_interfaces) _validate_interface_providers(manifest.id, manifest.provides_interfaces)
_validate_interface_requirements(manifest.id, manifest.requires_interfaces) _validate_interface_requirements(manifest.id, manifest.requires_interfaces)
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies) overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
if overlap: if overlap:
joined = ", ".join(sorted(overlap)) joined = ", ".join(sorted(overlap))
@@ -328,13 +380,18 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
joined = ", ".join(sorted(capability_overlap)) joined = ", ".join(sorted(capability_overlap))
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}") raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
def _validate_manifest_migration_spec(manifest: ModuleManifest) -> None:
if manifest.migration_spec is not None: if manifest.migration_spec is not None:
if manifest.migration_spec.module_id != manifest.id: if manifest.migration_spec.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}") raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location: if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location") raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
if manifest.frontend is not None:
def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
if manifest.frontend is None:
return
frontend = manifest.frontend frontend = manifest.frontend
if frontend.module_id != manifest.id: if frontend.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}") raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
@@ -347,15 +404,16 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name): if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}") raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
for route in (*frontend.routes, *frontend.settings_routes): for route in (*frontend.routes, *frontend.settings_routes):
if not route.path.startswith("/"): _validate_frontend_route(manifest.id, route.path, route.component)
raise RegistryError(f"Frontend route for module {manifest.id!r} must start with '/': {route.path!r}")
if not route.component.strip():
raise RegistryError(f"Frontend route {route.path!r} for module {manifest.id!r} must declare a component")
for item in frontend.nav_items: for item in frontend.nav_items:
_validate_nav_item(manifest.id, item) _validate_nav_item(manifest.id, item)
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item) def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
if not path.startswith("/"):
raise RegistryError(f"Frontend route for module {module_id!r} must start with '/': {path!r}")
if not component.strip():
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None: def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
_context: ModuleContext | None = None _context: ModuleContext | None = None
@@ -21,3 +23,41 @@ def get_runtime_context() -> ModuleContext | None:
def get_registry() -> object | None: def get_registry() -> object | None:
return _context.registry if _context is not None else None return _context.registry if _context is not None else None
class ModuleSettingsProxy:
def __init__(self, runtime: ModuleRuntimeState) -> None:
self._runtime = runtime
def __getattr__(self, name: str) -> Any:
return getattr(self._runtime.get_settings(), name)
class ModuleRuntimeState:
def __init__(self, module_name: str) -> None:
self.module_name = module_name
self.settings = ModuleSettingsProxy(self)
self._registry: object | None = None
self._settings: object | None = None
def configure_runtime(self, *, registry: object | None = None, settings: object | None = None) -> None:
if registry is not None:
self._registry = registry
if settings is not None:
self._settings = settings
def clear_runtime(self) -> None:
self._registry = None
self._settings = None
def get_registry(self) -> object | None:
return self._registry
def get_settings(self) -> object:
if self._settings is not None:
return self._settings
try:
from govoplan_core.settings import settings as legacy_settings
except ModuleNotFoundError as exc:
raise RuntimeError(f"GovOPlaN {self.module_name} runtime settings are not configured") from exc
return legacy_settings

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Literal
from sqlalchemy import inspect
ChangeOperation = Literal["created", "updated", "deleted"]
def object_state(obj: object) -> Any:
return inspect(obj)
def has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
def previous_value(obj: object, attr_name: str) -> str | None:
state = object_state(obj)
if attr_name not in state.attrs:
return None
history = state.attrs[attr_name].history
if not history.has_changes() or not history.deleted:
return None
value = history.deleted[0]
return str(value) if value is not None else None
def ensure_object_id(obj: object, new_id: Callable[[], str]) -> str:
resource_id = getattr(obj, "id", None)
if resource_id:
return str(resource_id)
resource_id = new_id()
setattr(obj, "id", resource_id)
return resource_id
def operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> ChangeOperation | None:
state = object_state(obj)
if state.pending:
return "created"
if state.deleted:
return "deleted"
if not has_attr_changes(state, changed_attrs):
return None
return "updated"
def operation_for_soft_deletable(
obj: object,
*,
changed_attrs: tuple[str, ...],
deleted_attr: str = "deleted_at",
) -> ChangeOperation | None:
state = object_state(obj)
if state.pending:
return "created"
if not has_attr_changes(state, changed_attrs):
return None
if deleted_attr in state.attrs:
history = state.attrs[deleted_attr].history
if history.has_changes():
if any(value is not None for value in history.added):
return "deleted"
if any(value is not None for value in history.deleted):
return "created"
return "updated"

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
import json import json
import logging
import os import os
from pathlib import Path from pathlib import Path
import re import re
@@ -25,6 +26,8 @@ from govoplan_core.server.config import ManifestFactory
from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.settings import settings from govoplan_core.settings import settings
logger = logging.getLogger(__name__)
# Historic development databases could be created partly through Alembic and # Historic development databases could be created partly through Alembic and
# partly through Base.metadata.create_all(). In that state Alembic still says # partly through Base.metadata.create_all(). In that state Alembic still says
# "2c..." while the 3d/4e file-storage tables already exist, so a normal # "2c..." while the 3d/4e file-storage tables already exist, so a normal
@@ -166,6 +169,15 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
} }
@dataclass(frozen=True, slots=True)
class _LegacyCreateAllSchemaState:
current: str | None
has_no_revision: bool
has_file_storage: bool
has_file_folders: bool
has_create_all_hierarchical_schema: bool
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class MigrationResult: class MigrationResult:
previous_revision: str | None previous_revision: str | None
@@ -233,10 +245,7 @@ def run_registered_module_migration_tasks(
dry_run: bool = False, dry_run: bool = False,
manifest_factories: tuple[ManifestFactory, ...] = (), manifest_factories: tuple[ManifestFactory, ...] = (),
) -> tuple[dict[str, object], ...]: ) -> tuple[dict[str, object], ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip())) active_phases = _normalized_migration_task_phases(phases)
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
url = database_url or settings.database_url url = database_url or settings.database_url
registry = _registered_module_registry( registry = _registered_module_registry(
database_url=url, database_url=url,
@@ -244,75 +253,81 @@ def run_registered_module_migration_tasks(
manifest_factories=manifest_factories, manifest_factories=manifest_factories,
) )
manifests = {manifest.id: manifest for manifest in registry.manifests()} manifests = {manifest.id: manifest for manifest in registry.manifests()}
ordered_ids = tuple(dict.fromkeys([ ordered_ids = _ordered_migration_task_module_ids(migration_module_order, manifests)
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
records: list[dict[str, object]] = [] records: list[dict[str, object]] = []
database = get_database() database = get_database()
with database.SessionLocal() as session: with database.SessionLocal() as session:
for phase in active_phases: for phase in active_phases:
for manifest, task in _iter_phase_migration_tasks(phase, ordered_ids=ordered_ids, manifests=manifests):
_run_registered_module_migration_task(
session,
manifest=manifest,
task=task,
database_url=url,
dry_run=dry_run,
records=records,
)
return tuple(records)
def _normalized_migration_task_phases(phases: tuple[str, ...] | list[str]) -> tuple[str, ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
return active_phases
def _ordered_migration_task_module_ids(
migration_module_order: tuple[str, ...] | list[str] | None,
manifests: Mapping[str, object],
) -> tuple[str, ...]:
return tuple(dict.fromkeys([
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
def _iter_phase_migration_tasks(
phase: str,
*,
ordered_ids: tuple[str, ...],
manifests: Mapping[str, object],
) -> Iterable[tuple[object, object]]:
for module_id in ordered_ids: for module_id in ordered_ids:
manifest = manifests.get(module_id) manifest = manifests.get(module_id)
if manifest is None or manifest.migration_spec is None: migration_spec = getattr(manifest, "migration_spec", None)
if manifest is None or migration_spec is None:
continue continue
for task in manifest.migration_spec.migration_tasks: for task in migration_spec.migration_tasks:
if task.phase != phase: if task.phase == phase:
continue yield manifest, task
record: dict[str, object] = {
"module_id": manifest.id,
"task_id": task.task_id, def _run_registered_module_migration_task(
"phase": task.phase, session: object,
"summary": task.summary, *,
"task_version": task.task_version, manifest: object,
"safety": task.safety, task: object,
"idempotent": task.idempotent, database_url: str,
"dry_run": dry_run, dry_run: bool,
} records: list[dict[str, object]],
if task.timeout_seconds is not None: ) -> None:
record["timeout_seconds"] = task.timeout_seconds record = _migration_task_record(manifest, task, dry_run=dry_run)
if not task.idempotent: _validate_migration_task_can_run(manifest, task, record, records, dry_run=dry_run)
record.update({"status": "blocked", "message": "Task is not idempotent."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
records=tuple(records),
)
if dry_run: if dry_run:
record.update({"status": "skipped", "message": "Dry run; executor was not called."}) record.update({"status": "skipped", "message": "Dry run; executor was not called."})
records.append(record) records.append(record)
continue return
if task.executor is None: normalized = _execute_module_migration_task(
record.update({"status": "blocked", "message": "Task has no executor."}) session,
records.append(record) manifest=manifest,
raise ModuleMigrationTaskExecutionError( task=task,
f"Module migration task {manifest.id}/{task.task_id} has no executor.", record=record,
records=tuple(records), records=records,
) database_url=database_url,
context = ModuleMigrationTaskContext(
module_id=manifest.id,
task_id=task.task_id,
phase=task.phase,
database_url=url,
target_version=manifest.version,
session=session,
dry_run=dry_run, dry_run=dry_run,
metadata=task.metadata,
) )
try:
result = task.executor(context)
normalized = _normalize_migration_task_result(result)
except Exception as exc:
session.rollback()
record.update({
"status": "blocked",
"message": f"{type(exc).__name__}: {exc}",
})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} failed.",
records=tuple(records),
) from exc
record.update({ record.update({
"status": normalized.status, "status": normalized.status,
"message": normalized.message, "message": normalized.message,
@@ -326,7 +341,102 @@ def run_registered_module_migration_tasks(
records=tuple(records), records=tuple(records),
) )
session.commit() session.commit()
return tuple(records)
def _migration_task_record(manifest: object, task: object, *, dry_run: bool) -> dict[str, object]:
record: dict[str, object] = {
"module_id": manifest.id,
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
"dry_run": dry_run,
}
if task.timeout_seconds is not None:
record["timeout_seconds"] = task.timeout_seconds
return record
def _validate_migration_task_can_run(
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
*,
dry_run: bool,
) -> None:
if not task.idempotent:
_block_migration_task(
manifest,
task,
record,
records,
message="Task is not idempotent.",
error=f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
)
if not dry_run and task.executor is None:
_block_migration_task(
manifest,
task,
record,
records,
message="Task has no executor.",
error=f"Module migration task {manifest.id}/{task.task_id} has no executor.",
)
def _block_migration_task(
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
*,
message: str,
error: str,
) -> None:
record.update({"status": "blocked", "message": message})
records.append(record)
raise ModuleMigrationTaskExecutionError(
error,
records=tuple(records),
)
def _execute_module_migration_task(
session: object,
*,
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
database_url: str,
dry_run: bool,
) -> ModuleMigrationTaskResult:
context = ModuleMigrationTaskContext(
module_id=manifest.id,
task_id=task.task_id,
phase=task.phase,
database_url=database_url,
target_version=manifest.version,
session=session,
dry_run=dry_run,
metadata=task.metadata,
)
try:
return _normalize_migration_task_result(task.executor(context))
except Exception as exc:
session.rollback()
record.update({
"status": "blocked",
"message": f"{type(exc).__name__}: {exc}",
})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} failed.",
records=tuple(records),
) from exc
def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult: def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult:
@@ -524,18 +634,21 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
def _row_count(connection, table_name: str) -> int: def _row_count(connection, table_name: str) -> int:
quoted = _quoted_table_name(connection, table_name) quoted = _quoted_table_name(connection, table_name)
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text statement = text(f"SELECT COUNT(*) FROM {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
return int(connection.execute(statement).scalar_one())
def _drop_table(connection, table_name: str) -> None: def _drop_table(connection, table_name: str) -> None:
quoted = _quoted_table_name(connection, table_name) quoted = _quoted_table_name(connection, table_name)
connection.execute(text(f"DROP TABLE {quoted}")) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text statement = text(f"DROP TABLE {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
connection.execute(statement)
def _rename_table(connection, old_name: str, new_name: str) -> None: def _rename_table(connection, old_name: str, new_name: str) -> None:
quoted_old = _quoted_table_name(connection, old_name) quoted_old = _quoted_table_name(connection, old_name)
quoted_new = _quoted_table_name(connection, new_name) quoted_new = _quoted_table_name(connection, new_name)
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}")) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text statement = text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
connection.execute(statement)
def _quoted_table_name(connection, table_name: str) -> str: def _quoted_table_name(connection, table_name: str) -> str:
@@ -715,7 +828,8 @@ def reconcile_covered_alembic_dependency_heads(
for revision_id in current: for revision_id in current:
try: try:
revision = script.get_revision(revision_id) revision = script.get_revision(revision_id)
except Exception: except Exception as exc:
logger.debug("Skipping Alembic revision %s while pruning dependency heads: %s", revision_id, exc, exc_info=True)
continue continue
ancestors = { ancestors = {
item.revision item.revision
@@ -749,15 +863,34 @@ def reconcile_legacy_create_all_schema(
""" """
url = database_url or settings.database_url url = database_url or settings.database_url
engine = create_engine(url) state = _legacy_create_all_schema_state(url)
target = _legacy_create_all_reconciliation_target(state)
if target is None:
return None
if target == REVISION_HIERARCHICAL_SETTINGS:
_backfill_user_lock_state_for_create_all_schema(url)
command.stamp(alembic_config(database_url=url, migration_track=migration_track), target)
return target
def _legacy_create_all_schema_state(database_url: str) -> _LegacyCreateAllSchemaState:
engine = create_engine(database_url)
try: try:
with engine.connect() as connection: with engine.connect() as connection:
heads = MigrationContext.configure(connection).get_current_heads() heads = MigrationContext.configure(connection).get_current_heads()
current = heads[0] if len(heads) == 1 else None
has_no_revision = len(heads) == 0
schema = inspect(connection) schema = inspect(connection)
tables = set(schema.get_table_names()) tables = set(schema.get_table_names())
return _legacy_create_all_schema_state_from_inspection(heads, schema, tables)
finally:
engine.dispose()
def _legacy_create_all_schema_state_from_inspection(
heads: tuple[str, ...],
schema: object,
tables: set[str],
) -> _LegacyCreateAllSchemaState:
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all( has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table]) _has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
for table in _FILE_STORAGE_TABLES for table in _FILE_STORAGE_TABLES
@@ -767,39 +900,30 @@ def reconcile_legacy_create_all_schema(
"file_folders", "file_folders",
_FILE_STORAGE_COLUMNS["file_folders"], _FILE_STORAGE_COLUMNS["file_folders"],
) )
has_create_all_hierarchical_schema = _has_create_all_schema_through_hierarchical_settings(schema, tables) return _LegacyCreateAllSchemaState(
finally: current=heads[0] if len(heads) == 1 else None,
engine.dispose() has_no_revision=len(heads) == 0,
has_file_storage=has_file_storage,
has_file_folders=has_file_folders,
has_create_all_hierarchical_schema=_has_create_all_schema_through_hierarchical_settings(schema, tables),
)
target: str | None = None
if current == REVISION_AUTH_RBAC and has_file_storage and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current == REVISION_AUTH_RBAC and has_file_storage:
target = REVISION_FILE_STORAGE
elif current == REVISION_FILE_STORAGE and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current == REVISION_FILE_FOLDERS and has_create_all_hierarchical_schema:
# Development DBs may be stamped at 4e after earlier create_all
# reconciliation even though later tables/columns were already created
# by a newer model set. Skip only when that complete known schema is
# present, then let newer migrations such as mail credential usernames
# run normally.
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif has_no_revision and has_create_all_hierarchical_schema:
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif has_no_revision and has_file_storage and has_file_folders:
# This is the other create_all-only development shape. The strict
# column checks above ensure that we only stamp a complete known schema.
target = REVISION_FILE_FOLDERS
if target is None: def _legacy_create_all_reconciliation_target(state: _LegacyCreateAllSchemaState) -> str | None:
if state.current == REVISION_AUTH_RBAC and state.has_file_storage and state.has_file_folders:
return REVISION_FILE_FOLDERS
if state.current == REVISION_AUTH_RBAC and state.has_file_storage:
return REVISION_FILE_STORAGE
if state.current == REVISION_FILE_STORAGE and state.has_file_folders:
return REVISION_FILE_FOLDERS
if state.current == REVISION_FILE_FOLDERS and state.has_create_all_hierarchical_schema:
return REVISION_HIERARCHICAL_SETTINGS
if state.has_no_revision and state.has_create_all_hierarchical_schema:
return REVISION_HIERARCHICAL_SETTINGS
if state.has_no_revision and state.has_file_storage and state.has_file_folders:
return REVISION_FILE_FOLDERS
return None return None
command.stamp(alembic_config(database_url=url, migration_track=migration_track), target)
return target
def migrate_database( def migrate_database(
*, *,

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
import time
import weakref
from collections.abc import Iterator
from sqlalchemy import event
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.engine.interfaces import ExecutionContext
@dataclass(slots=True)
class QueryMetrics:
query_count: int = 0
total_ms: float = 0.0
slowest_ms: float = 0.0
error_count: int = 0
def record(self, elapsed_ms: float, *, failed: bool = False) -> None:
self.query_count += 1
self.total_ms += elapsed_ms
self.slowest_ms = max(self.slowest_ms, elapsed_ms)
if failed:
self.error_count += 1
_current_metrics: ContextVar[QueryMetrics | None] = ContextVar("govoplan_query_metrics", default=None)
_instrumented_engines: weakref.WeakSet[Engine] = weakref.WeakSet()
_START_STACK_KEY = "govoplan_query_metric_starts"
@contextmanager
def collect_query_metrics() -> Iterator[QueryMetrics]:
metrics = QueryMetrics()
token = _current_metrics.set(metrics)
try:
yield metrics
finally:
_current_metrics.reset(token)
def current_query_metrics() -> QueryMetrics | None:
return _current_metrics.get()
def instrument_engine(engine: Engine) -> Engine:
if engine in _instrumented_engines:
return engine
event.listen(engine, "before_cursor_execute", _before_cursor_execute)
event.listen(engine, "after_cursor_execute", _after_cursor_execute)
event.listen(engine, "handle_error", _handle_error)
_instrumented_engines.add(engine)
return engine
def _before_cursor_execute(
conn: Connection,
_cursor: object,
_statement: str,
_parameters: object,
_context: ExecutionContext,
_executemany: bool,
) -> None:
if current_query_metrics() is None:
return
starts = conn.info.setdefault(_START_STACK_KEY, [])
starts.append(time.perf_counter())
def _after_cursor_execute(
conn: Connection,
_cursor: object,
_statement: str,
_parameters: object,
_context: ExecutionContext,
_executemany: bool,
) -> None:
_record_elapsed(conn, failed=False)
def _handle_error(exception_context: object) -> None:
conn = getattr(exception_context, "connection", None)
if isinstance(conn, Connection):
_record_elapsed(conn, failed=True)
def _record_elapsed(conn: Connection, *, failed: bool) -> None:
metrics = current_query_metrics()
if metrics is None:
return
starts = conn.info.get(_START_STACK_KEY)
if not starts:
return
started_at = starts.pop()
elapsed_ms = (time.perf_counter() - started_at) * 1000
metrics.record(elapsed_ms, failed=failed)

View File

@@ -7,6 +7,8 @@ from sqlalchemy import create_engine
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.query_metrics import instrument_engine
def default_connect_args(database_url: str) -> dict[str, Any]: def default_connect_args(database_url: str) -> dict[str, Any]:
return {"check_same_thread": False} if database_url.startswith("sqlite") else {} return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -22,13 +24,13 @@ def create_database_engine(
merged_connect_args = dict(default_connect_args(database_url)) merged_connect_args = dict(default_connect_args(database_url))
if connect_args: if connect_args:
merged_connect_args.update(connect_args) merged_connect_args.update(connect_args)
return create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs) return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
class DatabaseHandle: class DatabaseHandle:
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None: def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
self.database_url = database_url self.database_url = database_url
self.engine = engine or create_database_engine(database_url) self.engine = instrument_engine(engine) if engine is not None else create_database_engine(database_url)
self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False) self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False)
def session(self) -> Session: def session(self) -> Session:

View File

@@ -67,6 +67,32 @@ class ImapConfig(ImapServerConfig):
password: str | None = None password: str | None = None
def normalize_split_transport_credentials(value: object) -> object:
"""Move legacy transport username/password fields into credentials."""
if not isinstance(value, dict):
return value
data = dict(value)
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
for protocol in ("smtp", "imap"):
transport = data.get(protocol)
if not isinstance(transport, dict):
continue
next_transport = dict(transport)
next_credentials = dict(credentials.get(protocol) or {})
for field in ("username", "password"):
if field in next_transport and field not in next_credentials:
next_credentials[field] = next_transport[field]
next_transport.pop(field, None)
next_transport.pop("enabled", None)
data[protocol] = next_transport
if next_credentials:
credentials[protocol] = next_credentials
if credentials:
data["credentials"] = credentials
return data
def default_smtp_port(security: TransportSecurity | str | None) -> int: def default_smtp_port(security: TransportSecurity | str | None) -> int:
if security == TransportSecurity.TLS or security == "tls": if security == TransportSecurity.TLS or security == "tls":
return 465 return 465

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
"""Compatibility facade for privacy retention policy. """Compatibility facade for privacy retention policy.
Policy-owned behavior is provided by ``govoplan-policy`` through the Policy-owned behavior is provided by ``govoplan-policy`` through the
@@ -7,27 +5,22 @@ Policy-owned behavior is provided by ``govoplan-policy`` through the
without importing policy implementation code from core. without importing policy implementation code from core.
""" """
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Mapping from typing import Any, Mapping
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
from govoplan_core.core.runtime import get_registry from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.schemas import (
RETENTION_DAY_KEYS as RETENTION_DAY_KEYS,
RETENTION_POLICY_FIELD_KEYS,
default_allow_lower_level_limits as default_allow_lower_level_limits,
normalize_allow_lower_level_limits,
)
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy" PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
_DEFAULT_POLICY = { _DEFAULT_POLICY = {
"store_raw_campaign_json": True, "store_raw_campaign_json": True,
"raw_campaign_json_retention_days": None, "raw_campaign_json_retention_days": None,
@@ -122,28 +115,10 @@ def _policy_data(settings_payload: Mapping[str, Any] | None) -> dict[str, Any]:
for key in RETENTION_POLICY_FIELD_KEYS: for key in RETENTION_POLICY_FIELD_KEYS:
if key in raw: if key in raw:
payload[key] = raw[key] payload[key] = raw[key]
payload["allow_lower_level_limits"] = _normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True) payload["allow_lower_level_limits"] = normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True)
return payload return payload
def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, Mapping):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def privacy_policy_from_settings(item: object) -> Any: def privacy_policy_from_settings(item: object) -> Any:
service = _runtime_service() service = _runtime_service()
if service is not None: if service is not None:

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
__all__ = [
"RETENTION_DAY_KEYS",
"RETENTION_POLICY_FIELD_KEYS",
"PrivacyRetentionPolicyItem",
"PrivacyRetentionPolicyPatchItem",
"default_allow_lower_level_limits",
"normalize_allow_lower_level_limits",
]

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Mapping
@dataclass(frozen=True, slots=True)
class HttpFetchResponse:
status: int
headers: dict[str, str]
body: bytes
def text(self, encoding: str = "utf-8") -> str:
return self.body.decode(encoding)
def is_http_url(value: str) -> bool:
try:
validate_http_url(value)
except ValueError:
return False
return True
def validate_http_url(value: str, *, label: str = "URL") -> str:
parsed = urllib.parse.urlparse(str(value).strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"{label} must be an absolute HTTP(S) URL.")
if parsed.username or parsed.password:
raise ValueError(f"{label} must not include embedded credentials.")
return urllib.parse.urlunparse(parsed)
def fetch_http(
url: str,
*,
timeout: float = 15,
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
) -> HttpFetchResponse:
request = urllib.request.Request(
validate_http_url(url, label=label),
headers=dict(headers or {}),
method=method,
)
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - URL is validated by validate_http_url. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
return HttpFetchResponse(
status=int(getattr(response, "status", 0)),
headers=dict(response.headers.items()),
body=response.read(),
)
def fetch_http_text(
url: str,
*,
timeout: float = 15,
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
encoding: str = "utf-8",
) -> str:
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers).text(encoding)

View File

@@ -95,6 +95,11 @@ MODULE_SYSTEM_SCOPES = frozenset(
for legacy, module in LEGACY_TO_MODULE_SCOPES.items() for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
if legacy.startswith("system:") if legacy.startswith("system:")
) )
LEGACY_SYSTEM_SCOPES = frozenset(
legacy
for legacy in LEGACY_TO_MODULE_SCOPES
if legacy.startswith("system:")
)
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
@@ -110,7 +115,7 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool: def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
granted = list(scopes) granted = list(scopes)
if required in MODULE_SYSTEM_SCOPES: if required in MODULE_SYSTEM_SCOPES or required in LEGACY_SYSTEM_SCOPES:
return "*" in granted or "system:*" in granted or any( return "*" in granted or "system:*" in granted or any(
scope != "tenant:*" and scopes_grant([scope], alias) scope != "tenant:*" and scopes_grant([scope], alias)
for scope in granted for scope in granted

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable from typing import Iterable
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PermissionDefinition: class PermissionDefinition:
@@ -96,28 +98,6 @@ KNOWN_SCOPES = frozenset(item.scope for item in ALL_PERMISSIONS)
TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS) TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS)
SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS) SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS)
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
# Only names that are no longer canonical remain runtime aliases. Canonical
# permissions keep their narrow meaning; the Alembic migration expands old
# role records once so upgraded installations do not lose prior access.
"campaign:write": frozenset({
"campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share",
"recipients:read", "recipients:write", "recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"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",
}),
"admin:users:write": frozenset({"admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({"admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({"system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"}),
}
DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = { DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = {
"owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True}, "owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True},
"tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [ "tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
# Only names that are no longer canonical remain runtime aliases. Canonical
# permissions keep their narrow meaning; migrations expand old role records
# once so upgraded installations do not lose prior access.
"campaign:write": frozenset({
"campaign:create",
"campaign:update",
"campaign:copy",
"campaign:archive",
"campaign:delete",
"campaign:share",
"recipients:read",
"recipients:write",
"recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"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",
}),
"admin:users:write": frozenset({
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:roles:assign",
"admin:groups:manage_members",
}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({
"admin:settings:read",
"admin:settings:write",
"admin:api_keys:read",
"admin:api_keys:create",
"admin:api_keys:revoke",
}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({
"system:access:assign",
"system:roles:assign",
"system:accounts:create",
"system:accounts:update",
"system:accounts:suspend",
}),
}

View File

@@ -38,8 +38,8 @@ def _normalize_fernet_key(value: str) -> bytes:
try: try:
Fernet(candidate) Fernet(candidate)
return candidate return candidate
except Exception: except (TypeError, ValueError):
pass raw = None
try: try:
raw = base64.b64decode(candidate) raw = base64.b64decode(candidate)
except Exception as exc: except Exception as exc:

View File

@@ -13,52 +13,13 @@ from govoplan_core.server.route_validation import validate_no_route_collisions
def create_app(config: GovoplanServerConfig | str | None = None): def create_app(config: GovoplanServerConfig | str | None = None):
if isinstance(config, str) or config is None: server_config = _server_config(config)
server_config = load_server_config(config) _configure_app_database(server_config)
else: registry, available_modules = _server_module_registry(server_config)
server_config = config api_router = _server_api_router(server_config, registry)
lifecycle = _server_lifecycle(server_config, registry=registry, available_modules=available_modules)
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
if database_url:
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
configure_database(str(database_url), dispose_previous=dispose_previous)
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
startup_available_modules = available_module_manifests(
server_config.manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
api_router = APIRouter(prefix=server_config.api_prefix)
for router in server_config.base_routers:
api_router.include_router(router)
lifecycle = ModuleLifecycleManager(
registry=registry,
available_modules=available_modules,
settings=server_config.settings,
api_prefix=server_config.api_prefix,
manifest_factories=tuple(server_config.manifest_factories),
module_context_data=server_config.module_context_data,
)
lifecycle.configure_runtime() lifecycle.configure_runtime()
api_router.include_router(create_platform_router(settings=server_config.settings))
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
if contribution.should_include(server_config.settings, registry):
api_router.include_router(contribution.router)
validate_no_route_collisions(api_router, owner="server startup routes")
app = create_govoplan_app( app = create_govoplan_app(
title=server_config.title, title=server_config.title,
version=server_config.version, version=server_config.version,
@@ -74,4 +35,61 @@ def create_app(config: GovoplanServerConfig | str | None = None):
return app return app
def _server_config(config: GovoplanServerConfig | str | None) -> GovoplanServerConfig:
if isinstance(config, str) or config is None:
return load_server_config(config)
return config
def _configure_app_database(server_config: GovoplanServerConfig) -> None:
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
if database_url:
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
configure_database(str(database_url), dispose_previous=dispose_previous)
def _server_module_registry(server_config: GovoplanServerConfig):
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
startup_available_modules = available_module_manifests(
server_config.manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
return registry, available_modules
def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRouter:
api_router = APIRouter(prefix=server_config.api_prefix)
for router in server_config.base_routers:
api_router.include_router(router)
api_router.include_router(create_platform_router(settings=server_config.settings))
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
if contribution.should_include(server_config.settings, registry):
api_router.include_router(contribution.router)
validate_no_route_collisions(api_router, owner="server startup routes")
return api_router
def _server_lifecycle(
server_config: GovoplanServerConfig,
*,
registry,
available_modules,
) -> ModuleLifecycleManager:
return ModuleLifecycleManager(
registry=registry,
available_modules=available_modules,
settings=server_config.settings,
api_prefix=server_config.api_prefix,
manifest_factories=tuple(server_config.manifest_factories),
module_context_data=server_config.module_context_data,
)
app = create_app() app = create_app()

View File

@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import logging
import os
import time
from collections.abc import AsyncIterator, Callable, Iterable from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AbstractAsyncContextManager from contextlib import AbstractAsyncContextManager
from typing import Any from typing import Any
@@ -9,9 +12,19 @@ from fastapi.middleware.cors import CORSMiddleware
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.server.conditional_requests import conditional_json_get_middleware from govoplan_core.server.conditional_requests import conditional_json_get_middleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]] LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
logger = logging.getLogger("govoplan.request")
def _slow_request_threshold_ms() -> float:
raw = os.getenv("GOVOPLAN_SLOW_REQUEST_MS", "500").strip()
try:
return float(raw)
except ValueError:
return 500.0
def create_govoplan_app( def create_govoplan_app(
@@ -26,6 +39,7 @@ def create_govoplan_app(
) -> FastAPI: ) -> FastAPI:
app = FastAPI(title=title, version=version, lifespan=lifespan) app = FastAPI(title=title, version=version, lifespan=lifespan)
app.state.govoplan_registry = registry app.state.govoplan_registry = registry
slow_request_threshold_ms = _slow_request_threshold_ms()
@app.middleware("http") @app.middleware("http")
async def request_correlation_context(request: Request, call_next): async def request_correlation_context(request: Request, call_next):
@@ -39,6 +53,42 @@ def create_govoplan_app(
response.headers["X-Correlation-ID"] = correlation_id response.headers["X-Correlation-ID"] = correlation_id
return response return response
@app.middleware("http")
async def slow_request_logging(request: Request, call_next):
started_at = time.perf_counter()
with collect_query_metrics() as db_metrics:
try:
response = await call_next(request)
except Exception:
elapsed_ms = (time.perf_counter() - started_at) * 1000
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
logger.warning(
"slow request failed method=%s path=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
request.method,
request.url.path,
elapsed_ms,
db_metrics.query_count,
db_metrics.total_ms,
db_metrics.slowest_ms,
db_metrics.error_count,
exc_info=True,
)
raise
elapsed_ms = (time.perf_counter() - started_at) * 1000
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
logger.warning(
"slow request method=%s path=%s status=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
request.method,
request.url.path,
response.status_code,
elapsed_ms,
db_metrics.query_count,
db_metrics.total_ms,
db_metrics.slowest_ms,
db_metrics.error_count,
)
return response
app.middleware("http")(conditional_json_get_middleware) app.middleware("http")(conditional_json_get_middleware)
origins = [item.strip() for item in cors_origins if item.strip()] origins = [item.strip() for item in cors_origins if item.strip()]

View File

@@ -17,15 +17,15 @@ class Settings(BaseSettings):
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE") tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE") tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE") tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops", alias="ENABLED_MODULES") enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops", alias="ENABLED_MODULES")
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK") migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL") redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED") celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
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")
s3_access_key_id: str = Field(default="GKmultimailerdev0000000000000000", alias="S3_ACCESS_KEY_ID") s3_access_key_id: str = Field(default="GKgovoplandev0000000000000000000", alias="S3_ACCESS_KEY_ID")
s3_secret_access_key: str = Field(default="multimailer-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY") s3_secret_access_key: str = Field(default="govoplan-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY")
s3_bucket: str = Field(default="attachments", alias="S3_BUCKET") s3_bucket: str = Field(default="attachments", alias="S3_BUCKET")
# Managed file storage. Development defaults to local filesystem storage; # Managed file storage. Development defaults to local filesystem storage;
@@ -41,19 +41,19 @@ class Settings(BaseSettings):
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES") file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES") file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
auth_session_cookie_name: str = Field(default="msm_session", alias="AUTH_SESSION_COOKIE_NAME") auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME")
auth_csrf_cookie_name: str = Field(default="msm_csrf", alias="AUTH_CSRF_COOKIE_NAME") auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")
auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE") auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE") auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN") auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS") auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64") master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(default="send_email,append_sent,default", alias="CELERY_QUEUES") celery_queues: str = Field(default="send_email,append_sent,notifications,default", alias="CELERY_QUEUES")
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR") mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
# Development bootstrap only. Do not use this in production. # Development bootstrap only. Do not use this in production.
dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY") dev_bootstrap_api_key: str | None = Field(default="dev-govoplan-api-key", alias="DEV_BOOTSTRAP_API_KEY")
dev_auto_migrate_enabled: bool = Field(default=True, alias="DEV_AUTO_MIGRATE_ENABLED") dev_auto_migrate_enabled: bool = Field(default=True, alias="DEV_AUTO_MIGRATE_ENABLED")
dev_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED") dev_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED")
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD") dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")

View File

@@ -364,12 +364,79 @@ class ApiSmokeTests(unittest.TestCase):
json={"email": "admin@example.local", "password": "test-admin"}, json={"email": "admin@example.local", "password": "test-admin"},
) )
self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.status_code, 200, response.text)
csrf = response.cookies.get("msm_csrf") csrf = response.cookies.get("govoplan_csrf")
self.assertTrue(csrf) self.assertTrue(csrf)
session_summary = self.client.get("/api/v1/auth/session")
self.assertEqual(session_summary.status_code, 200, session_summary.text)
session_payload = session_summary.json()
self.assertTrue(session_payload["authenticated"])
self.assertEqual(session_payload["auth_method"], "session")
self.assertNotIn("scopes", session_payload)
self.assertNotIn("roles", session_payload)
self.assertNotIn("groups", session_payload)
shell_summary = self.client.get("/api/v1/auth/shell")
self.assertEqual(shell_summary.status_code, 200, shell_summary.text)
shell_payload = shell_summary.json()
self.assertEqual(shell_payload["principal"]["auth_method"], "session")
self.assertFalse(shell_payload["profile_loaded"])
self.assertFalse(shell_payload["roles_loaded"])
self.assertFalse(shell_payload["groups_loaded"])
self.assertIsInstance(shell_payload["scopes"], list)
self.assertTrue(shell_payload["scopes"])
self.assertTrue(shell_payload["tenants"])
self.assertTrue(all(membership["roles"] == [] for membership in shell_payload["tenants"]))
self.assertNotIn("roles", shell_payload)
self.assertNotIn("groups", shell_payload)
self.assertNotIn("available_languages", shell_payload)
self.assertNotIn("default_language", shell_payload)
profile_summary = self.client.get("/api/v1/auth/profile")
self.assertEqual(profile_summary.status_code, 200, profile_summary.text)
profile_payload = profile_summary.json()
self.assertTrue(profile_payload["profile_loaded"])
self.assertEqual(profile_payload["user"]["id"], session_payload["user"]["id"])
self.assertIn("available_languages", profile_payload)
self.assertIn("default_language", profile_payload)
self.assertNotIn("roles", profile_payload)
self.assertNotIn("groups", profile_payload)
roles_summary = self.client.get("/api/v1/auth/roles")
self.assertEqual(roles_summary.status_code, 200, roles_summary.text)
roles_payload = roles_summary.json()
self.assertTrue(roles_payload["roles_loaded"])
self.assertTrue(roles_payload["roles"])
self.assertNotIn("groups", roles_payload)
groups_summary = self.client.get("/api/v1/auth/groups")
self.assertEqual(groups_summary.status_code, 200, groups_summary.text)
groups_payload = groups_summary.json()
self.assertTrue(groups_payload["groups_loaded"])
self.assertIsInstance(groups_payload["groups"], list)
self.assertNotIn("roles", groups_payload)
api_key_session = self.client.get("/api/v1/auth/session", headers={"X-API-Key": "test-api-key"})
self.assertEqual(api_key_session.status_code, 200, api_key_session.text)
self.assertEqual(api_key_session.json()["auth_method"], "api_key")
api_key_shell = self.client.get("/api/v1/auth/shell", headers={"X-API-Key": "test-api-key"})
self.assertEqual(api_key_shell.status_code, 200, api_key_shell.text)
api_key_shell_payload = api_key_shell.json()
self.assertEqual(api_key_shell_payload["principal"]["auth_method"], "api_key")
self.assertFalse(api_key_shell_payload["profile_loaded"])
self.assertFalse(api_key_shell_payload["roles_loaded"])
self.assertFalse(api_key_shell_payload["groups_loaded"])
self.assertIsInstance(api_key_shell_payload["scopes"], list)
me = self.client.get("/api/v1/auth/me") me = self.client.get("/api/v1/auth/me")
self.assertEqual(me.status_code, 200, me.text) self.assertEqual(me.status_code, 200, me.text)
me_payload = me.json() me_payload = me.json()
self.assertEqual(session_payload["user"]["id"], me_payload["user"]["id"])
self.assertEqual(session_payload["tenant"]["id"], me_payload["tenant"]["id"])
self.assertEqual(shell_payload["user"]["id"], me_payload["user"]["id"])
self.assertEqual(shell_payload["tenant"]["id"], me_payload["tenant"]["id"])
self.assertTrue(me_payload["profile_loaded"])
self.assertEqual(me_payload["principal"]["account_id"], me_payload["user"]["account_id"]) self.assertEqual(me_payload["principal"]["account_id"], me_payload["user"]["account_id"])
self.assertEqual(me_payload["principal"]["membership_id"], me_payload["user"]["id"]) self.assertEqual(me_payload["principal"]["membership_id"], me_payload["user"]["id"])
self.assertEqual(me_payload["principal"]["tenant_id"], me_payload["tenant"]["id"]) self.assertEqual(me_payload["principal"]["tenant_id"], me_payload["tenant"]["id"])
@@ -423,6 +490,20 @@ class ApiSmokeTests(unittest.TestCase):
imap_host="mock.imap", imap_host="mock.imap",
) )
bootstrapped = self.client.get(
f"/api/v1/mail/profiles/{profile_id}/mailbox/bootstrap",
headers=headers,
params={"folder": "INBOX", "limit": 2},
)
self.assertEqual(bootstrapped.status_code, 200, bootstrapped.text)
bootstrap_payload = bootstrapped.json()
self.assertEqual(bootstrap_payload["folder"], "INBOX")
self.assertTrue(bootstrap_payload["folders"]["ok"])
self.assertFalse(bootstrap_payload["folders"]["from_cache"])
self.assertFalse(bootstrap_payload["messages"]["from_cache"])
self.assertEqual(bootstrap_payload["messages"]["total_count"], 3)
self.assertEqual(len(bootstrap_payload["messages"]["messages"]), 2)
listed = self.client.get( listed = self.client.get(
f"/api/v1/mail/profiles/{profile_id}/mailbox/messages", f"/api/v1/mail/profiles/{profile_id}/mailbox/messages",
headers=headers, headers=headers,
@@ -433,6 +514,7 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(payload["folder"], "INBOX") self.assertEqual(payload["folder"], "INBOX")
self.assertEqual(payload["total_count"], 3) self.assertEqual(payload["total_count"], 3)
self.assertEqual(len(payload["messages"]), 2) self.assertEqual(len(payload["messages"]), 2)
self.assertTrue(payload["from_cache"])
self.assertTrue(all(message["folder"] == "INBOX" for message in payload["messages"])) self.assertTrue(all(message["folder"] == "INBOX" for message in payload["messages"]))
self.assertTrue(payload["cursor_stable"]) self.assertTrue(payload["cursor_stable"])
self.assertFalse(payload["full"]) self.assertFalse(payload["full"])
@@ -449,6 +531,14 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(len(next_payload["messages"]), 1) self.assertEqual(len(next_payload["messages"]), 1)
self.assertFalse(next_payload["next_cursor"]) self.assertFalse(next_payload["next_cursor"])
cached_next_page = self.client.get(
f"/api/v1/mail/profiles/{profile_id}/mailbox/messages",
headers=headers,
params={"folder": "INBOX", "cursor": payload["next_cursor"]},
)
self.assertEqual(cached_next_page.status_code, 200, cached_next_page.text)
self.assertTrue(cached_next_page.json()["from_cache"])
stale_cursor = encode_keyset_cursor( stale_cursor = encode_keyset_cursor(
"mail.mailbox.messages.v1", "mail.mailbox.messages.v1",
fingerprint=keyset_query_fingerprint( fingerprint=keyset_query_fingerprint(
@@ -739,7 +829,7 @@ class ApiSmokeTests(unittest.TestCase):
schema = self.client.get("/api/v1/schemas/campaign", headers=headers) schema = self.client.get("/api/v1/schemas/campaign", headers=headers)
self.assertEqual(schema.status_code, 200, schema.text) self.assertEqual(schema.status_code, 200, schema.text)
self.assertEqual(schema.json()["title"], "MultiMailer Campaign") self.assertEqual(schema.json()["title"], "GovOPlaN Campaign")
dev_mailbox = self.client.get("/api/v1/dev/mailbox/messages", headers=headers) dev_mailbox = self.client.get("/api/v1/dev/mailbox/messages", headers=headers)
self.assertEqual(dev_mailbox.status_code, 404, dev_mailbox.text) self.assertEqual(dev_mailbox.status_code, 404, dev_mailbox.text)
@@ -3269,7 +3359,7 @@ class ApiSmokeTests(unittest.TestCase):
"invoices/archive/202605-010001-report.XLSX", "invoices/archive/202605-010001-report.XLSX",
"invoices/202605-010001-90100010-9601741.XLSX", "invoices/202605-010001-90100010-9601741.XLSX",
}) })
self.assertFalse(any("multimailer-managed-build" in value for value in resolved_paths)) self.assertFalse(any("govoplan-managed-build" in value for value in resolved_paths))
jobs = self.client.get( jobs = self.client.get(
f"/api/v1/campaigns/{campaign_id}/jobs", f"/api/v1/campaigns/{campaign_id}/jobs",

27
tests/test_http_fetch.py Normal file
View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import unittest
from govoplan_core.security.http_fetch import is_http_url, validate_http_url
class HttpFetchTests(unittest.TestCase):
def test_validate_http_url_accepts_absolute_http_urls_without_credentials(self) -> None:
self.assertEqual("https://example.test/catalog.json", validate_http_url("https://example.test/catalog.json"))
self.assertTrue(is_http_url("http://example.test/catalog.json"))
def test_validate_http_url_rejects_non_http_urls_and_embedded_credentials(self) -> None:
for value in (
"file:///etc/passwd",
"/relative/catalog.json",
"https://user@example.test/catalog.json",
"https://user:secret@example.test/catalog.json",
):
with self.subTest(value=value):
self.assertFalse(is_http_url(value))
with self.assertRaises(ValueError):
validate_http_url(value)
if __name__ == "__main__":
unittest.main()

32
tests/test_mail_config.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import unittest
from govoplan_core.mail.config import normalize_split_transport_credentials
class MailConfigTests(unittest.TestCase):
def test_normalize_split_transport_credentials_moves_legacy_auth_fields(self) -> None:
payload = normalize_split_transport_credentials(
{
"smtp": {"host": "smtp.example.test", "username": "smtp-user", "password": "smtp-secret"},
"imap": {"host": "imap.example.test", "enabled": True, "username": "imap-user"},
"credentials": {"smtp": {"username": "existing"}},
}
)
self.assertEqual(
{
"smtp": {"host": "smtp.example.test"},
"imap": {"host": "imap.example.test"},
"credentials": {
"smtp": {"username": "existing", "password": "smtp-secret"},
"imap": {"username": "imap-user"},
},
},
payload,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -14,7 +14,6 @@ import tempfile
import textwrap import textwrap
import tomllib import tomllib
import unittest import unittest
import urllib.error
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -35,6 +34,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from govoplan_core.core.events import event_context from govoplan_core.core.events import event_context
from govoplan_core.core import module_installer as module_installer_module
from govoplan_core.core.migrations import migration_metadata_plan from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.core.module_management import ( from govoplan_core.core.module_management import (
ModuleInstallPlan, ModuleInstallPlan,
@@ -227,7 +227,7 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests)) self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
self.assertEqual(manifests["campaigns"].dependencies, ()) self.assertEqual(manifests["campaigns"].dependencies, ())
self.assertTrue(manifests["campaigns"].required_capabilities) self.assertTrue(manifests["campaigns"].required_capabilities)
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail")) self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail", "notifications", "addresses"))
self.assertEqual(manifests["dashboard"].dependencies, ()) self.assertEqual(manifests["dashboard"].dependencies, ())
self.assertTrue(manifests["dashboard"].required_capabilities) self.assertTrue(manifests["dashboard"].required_capabilities)
self.assertEqual(manifests["docs"].dependencies, ()) self.assertEqual(manifests["docs"].dependencies, ())
@@ -272,14 +272,16 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read")) self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read")) self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read")) self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
self.assertFalse(scopes_grant_compatible(["tenant:*"], "system:tenants:read"))
self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read")) self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read"))
def test_core_webui_retired_legacy_admin_api_surface(self) -> None: def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
webui_src = Path(__file__).resolve().parents[1] / "webui" / "src" webui_src = Path(__file__).resolve().parents[1] / "webui" / "src"
legacy_admin_import_pattern = re.compile(r"api/admin(?=[\"'#?]|$)")
self.assertFalse((webui_src / "api" / "admin.ts").exists()) self.assertFalse((webui_src / "api" / "admin.ts").exists())
for path in webui_src.rglob("*.ts*"): for path in webui_src.rglob("*.ts*"):
with self.subTest(path=path.relative_to(webui_src)): with self.subTest(path=path.relative_to(webui_src)):
self.assertNotIn("api/admin", path.read_text(encoding="utf-8")) self.assertIsNone(legacy_admin_import_pattern.search(path.read_text(encoding="utf-8")))
def test_platform_modules_own_live_legacy_model_tables(self) -> None: def test_platform_modules_own_live_legacy_model_tables(self) -> None:
from govoplan_admin.backend.db.models import GovernanceTemplate from govoplan_admin.backend.db.models import GovernanceTemplate
@@ -2474,7 +2476,8 @@ finally:
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__]) Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
def fake_run(*_args, **kwargs): def fake_run(*_args, **kwargs):
if kwargs.get("shell"): argv = tuple(_args[0]) if _args else ()
if kwargs.get("shell") or argv == ("restart-fails",):
return SimpleNamespace(returncode=1, stdout="", stderr="restart failed") return SimpleNamespace(returncode=1, stdout="", stderr="restart failed")
return SimpleNamespace(returncode=0, stdout="", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="")
@@ -2562,6 +2565,43 @@ finally:
self.assertEqual(database_url, (result.record_path.parent / database_backup["database_url_secret"]).read_text(encoding="utf-8")) self.assertEqual(database_url, (result.record_path.parent / database_backup["database_url_secret"]).read_text(encoding="utf-8"))
self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8")) self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8"))
def test_module_installer_rejects_unsafe_external_database_hook_command(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unsafe-hook-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
backup_command = f"{sys.executable} -c {shlex.quote('print(1)')} && {sys.executable} -c {shlex.quote('print(2)')}"
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
}])
session.commit()
with self.assertRaisesRegex(module_installer_module.ModuleInstallerError, "Database backup command failed"):
run_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url="postgresql://db.example.invalid/govoplan",
runtime_dir=root / "installer",
migrate_database=True,
database_backup_command=backup_command,
database_restore_command="restore-database",
dry_run=True,
)
rejected = module_installer_module._run_operator_command("DATABASE_URL=postgres://db.example.invalid/govoplan pg_dump")
self.assertEqual(2, rejected.returncode)
self.assertIn("Environment assignments", rejected.stderr)
def test_module_installer_external_database_restore_check_runs_before_migrations(self) -> None: def test_module_installer_external_database_restore_check_runs_before_migrations(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-restore-check-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-installer-restore-check-", dir=_TEST_ROOT))
settings = _settings(root) settings = _settings(root)
@@ -2654,11 +2694,13 @@ finally:
queued = queue_module_installer_request( queued = queue_module_installer_request(
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
requested_by="user-1", requested_by="user-1",
tenant_id="tenant-1",
options={"migrate_database": True, "health_urls": ["http://127.0.0.1:8000/health"]}, options={"migrate_database": True, "health_urls": ["http://127.0.0.1:8000/health"]},
) )
request_id = str(queued["request_id"]) request_id = str(queued["request_id"])
self.assertEqual("queued", queued["status"]) self.assertEqual("queued", queued["status"])
self.assertEqual("tenant-1", queued["tenant_id"])
self.assertEqual("installer-trace-1", queued["trace"]["correlation_id"]) self.assertEqual("installer-trace-1", queued["trace"]["correlation_id"])
self.assertEqual((request_id,), tuple(item["request_id"] for item in list_module_installer_requests(runtime_dir=runtime_dir))) self.assertEqual((request_id,), tuple(item["request_id"] for item in list_module_installer_requests(runtime_dir=runtime_dir)))
claimed = claim_next_module_installer_request(runtime_dir=runtime_dir) claimed = claim_next_module_installer_request(runtime_dir=runtime_dir)
@@ -2675,7 +2717,7 @@ finally:
self.assertEqual("completed", updated["status"]) self.assertEqual("completed", updated["status"])
self.assertEqual("completed", read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)["status"]) self.assertEqual("completed", read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)["status"])
cancellable = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="user-1") cancellable = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="user-1", tenant_id="tenant-1")
cancelled = cancel_module_installer_request( cancelled = cancel_module_installer_request(
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
request_id=str(cancellable["request_id"]), request_id=str(cancellable["request_id"]),
@@ -2691,6 +2733,7 @@ finally:
) )
self.assertEqual("queued", retry["status"]) self.assertEqual("queued", retry["status"])
self.assertEqual(cancellable["request_id"], retry["retry_of"]) self.assertEqual(cancellable["request_id"], retry["retry_of"])
self.assertEqual("tenant-1", retry["tenant_id"])
settings = _settings(root) settings = _settings(root)
configure_database(settings.database_url) configure_database(settings.database_url)
@@ -3030,11 +3073,11 @@ finally:
"version_min": "0.1.0", "version_min": "0.1.0",
"version_max_exclusive": "0.2.0", "version_max_exclusive": "0.2.0",
}, modules["campaigns"]["requires_interfaces"]) }, modules["campaigns"]["requires_interfaces"])
self.assertEqual(["files", "mail"], modules["campaigns"]["optional_dependencies"]) self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
self.assertEqual("requires_review", modules["files"]["migration_safety"]) self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower()) self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.7", modules["files"]["version"]) self.assertEqual("0.1.8", modules["files"]["version"])
self.assertIn("@v0.1.7", modules["files"]["python_ref"]) self.assertIn("@v0.1.8", modules["files"]["python_ref"])
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None: def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
@@ -3067,16 +3110,6 @@ finally:
) )
body = signed_path.read_text(encoding="utf-8") body = signed_path.read_text(encoding="utf-8")
class _Response:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self) -> bytes:
return body.encode("utf-8")
env = { env = {
"GOVOPLAN_MODULE_PACKAGE_CATALOG_URL": "https://catalog.example.invalid/stable.json", "GOVOPLAN_MODULE_PACKAGE_CATALOG_URL": "https://catalog.example.invalid/stable.json",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE": str(cache_path), "GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE": str(cache_path),
@@ -3085,11 +3118,11 @@ finally:
} }
trusted_keys = {"release-remote": base64.b64encode(public_key).decode("ascii")} trusted_keys = {"release-remote": base64.b64encode(public_key).decode("ascii")}
with patch.dict(os.environ, env): with patch.dict(os.environ, env):
with patch("govoplan_core.core.module_package_catalog.urllib.request.urlopen", return_value=_Response()): with patch("govoplan_core.core.module_package_catalog.fetch_http_text", return_value=body):
fetched = validate_module_package_catalog(trusted_keys=trusted_keys) fetched = validate_module_package_catalog(trusted_keys=trusted_keys)
with patch( with patch(
"govoplan_core.core.module_package_catalog.urllib.request.urlopen", "govoplan_core.core.module_package_catalog.fetch_http_text",
side_effect=urllib.error.URLError("offline"), side_effect=OSError("offline"),
): ):
cached = validate_module_package_catalog(trusted_keys=trusted_keys) cached = validate_module_package_catalog(trusted_keys=trusted_keys)
@@ -3800,12 +3833,14 @@ finally:
self._run_physical_absence_probe(enabled_modules=enabled_modules, blocked_modules=blocked_modules) self._run_physical_absence_probe(enabled_modules=enabled_modules, blocked_modules=blocked_modules)
def test_module_route_factories_receive_runtime_settings(self) -> None: def test_module_route_factories_receive_runtime_settings(self) -> None:
app, settings = self._app_for_modules(("files", "mail")) app, settings = self._app_for_modules(("calendar", "files", "mail"))
self.assertIsNotNone(app) self.assertIsNotNone(app)
from govoplan_calendar.backend.runtime import get_settings as get_calendar_settings
from govoplan_files.backend.runtime import get_settings as get_files_settings from govoplan_files.backend.runtime import get_settings as get_files_settings
from govoplan_mail.backend.runtime import get_settings as get_mail_settings from govoplan_mail.backend.runtime import get_settings as get_mail_settings
self.assertIs(settings, get_calendar_settings())
self.assertIs(settings, get_files_settings()) self.assertIs(settings, get_files_settings())
self.assertIs(settings, get_mail_settings()) self.assertIs(settings, get_mail_settings())

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import unittest
from pydantic import ValidationError
from govoplan_core.privacy.schemas import (
RETENTION_POLICY_FIELD_KEYS,
PrivacyRetentionPolicyItem,
PrivacyRetentionPolicyPatchItem,
default_allow_lower_level_limits,
normalize_allow_lower_level_limits,
)
class PrivacySchemaContractTests(unittest.TestCase):
def test_default_allow_lower_level_limits_cover_every_retention_field(self) -> None:
self.assertEqual({key: True for key in RETENTION_POLICY_FIELD_KEYS}, default_allow_lower_level_limits())
def test_policy_item_fills_missing_lower_level_limits(self) -> None:
item = PrivacyRetentionPolicyItem.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
self.assertFalse(item.allow_lower_level_limits["audit_detail_level"])
self.assertTrue(item.allow_lower_level_limits["store_raw_campaign_json"])
def test_policy_patch_keeps_lower_level_limits_sparse(self) -> None:
patch = PrivacyRetentionPolicyPatchItem.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
self.assertEqual({"audit_detail_level": False}, patch.allow_lower_level_limits)
self.assertIsNone(normalize_allow_lower_level_limits("", fill_defaults=False))
def test_unknown_lower_level_limit_field_is_rejected(self) -> None:
with self.assertRaises(ValidationError):
PrivacyRetentionPolicyItem.model_validate({"allow_lower_level_limits": {"unknown": True}})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import os
from unittest import TestCase
from unittest.mock import patch
from fastapi import APIRouter
from fastapi.testclient import TestClient
from sqlalchemy import text
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.db.session import create_database_engine
from govoplan_core.server.fastapi import create_govoplan_app
class QueryMetricsTests(TestCase):
def test_collect_query_metrics_counts_instrumented_engine_queries(self) -> None:
engine = create_database_engine("sqlite:///:memory:")
try:
with collect_query_metrics() as metrics:
with engine.connect() as connection:
self.assertEqual(1, connection.execute(text("select 1")).scalar_one())
self.assertEqual(1, metrics.query_count)
self.assertGreaterEqual(metrics.total_ms, 0.0)
self.assertGreaterEqual(metrics.slowest_ms, 0.0)
self.assertEqual(0, metrics.error_count)
finally:
engine.dispose()
def test_slow_request_log_includes_query_metrics(self) -> None:
engine = create_database_engine("sqlite:///:memory:")
router = APIRouter()
@router.get("/query")
def query_route() -> dict[str, bool]:
with engine.connect() as connection:
connection.execute(text("select 1")).scalar_one()
return {"ok": True}
try:
with patch.dict(os.environ, {"GOVOPLAN_SLOW_REQUEST_MS": "0.001"}):
app = create_govoplan_app(
title="query metrics test",
version="0",
registry=PlatformRegistry(),
api_router=router,
)
with TestClient(app) as client, self.assertLogs("govoplan.request", level="WARNING") as logs:
response = client.get("/query")
self.assertEqual(200, response.status_code, response.text)
output = "\n".join(logs.output)
self.assertIn("db_query_count=1", output)
self.assertIn("db_time_ms=", output)
self.assertIn("db_slowest_ms=", output)
self.assertIn("db_error_count=0", output)
finally:
engine.dispose()

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import DateTime, String, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from govoplan_core.core.sqlalchemy_change_tracking import (
ensure_object_id,
operation_for_object,
operation_for_soft_deletable,
previous_value,
)
class Base(DeclarativeBase):
pass
class ExampleRecord(Base):
__tablename__ = "example_records"
id: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class SqlalchemyChangeTrackingTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine, expire_on_commit=False)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_pending_object_is_created_and_can_receive_id(self) -> None:
record = ExampleRecord(id="", name="Draft", deleted_at=None)
self.session.add(record)
self.assertEqual(ensure_object_id(record, lambda: "record-1"), "record-1")
self.assertEqual(record.id, "record-1")
self.assertEqual(operation_for_object(record, changed_attrs=("name",)), "created")
def test_changed_object_reports_update_and_previous_value(self) -> None:
record = ExampleRecord(id="record-1", name="Before", deleted_at=None)
self.session.add(record)
self.session.flush()
record.name = "After"
self.assertEqual(operation_for_object(record, changed_attrs=("name",)), "updated")
self.assertEqual(previous_value(record, "name"), "Before")
def test_soft_delete_and_restore_are_classified(self) -> None:
deleted_at = datetime(2026, 7, 13, tzinfo=timezone.utc)
record = ExampleRecord(id="record-1", name="Record", deleted_at=None)
self.session.add(record)
self.session.flush()
record.deleted_at = deleted_at
self.assertEqual(operation_for_soft_deletable(record, changed_attrs=("deleted_at",)), "deleted")
self.session.flush()
record.deleted_at = None
self.assertEqual(operation_for_soft_deletable(record, changed_attrs=("deleted_at",)), "created")
if __name__ == "__main__":
unittest.main()

View File

@@ -9,6 +9,7 @@
"version": "0.1.8", "version": "0.1.8",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui", "@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui", "@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui", "@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
@@ -18,6 +19,7 @@
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui", "@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui", "@govoplan/policy-webui": "file:../../govoplan-policy/webui",
@@ -61,6 +63,22 @@
} }
} }
}, },
"../../govoplan-addresses/webui": {
"name": "@govoplan/addresses-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-admin/webui": { "../../govoplan-admin/webui": {
"name": "@govoplan/admin-webui", "name": "@govoplan/admin-webui",
"version": "0.1.8", "version": "0.1.8",
@@ -229,6 +247,25 @@
} }
} }
}, },
"../../govoplan-notifications/webui": {
"name": "@govoplan/notifications-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-ops/webui": { "../../govoplan-ops/webui": {
"name": "@govoplan/ops-webui", "name": "@govoplan/ops-webui",
"version": "0.1.8", "version": "0.1.8",
@@ -1030,6 +1067,10 @@
"resolved": "../../govoplan-access/webui", "resolved": "../../govoplan-access/webui",
"link": true "link": true
}, },
"node_modules/@govoplan/addresses-webui": {
"resolved": "../../govoplan-addresses/webui",
"link": true
},
"node_modules/@govoplan/admin-webui": { "node_modules/@govoplan/admin-webui": {
"resolved": "../../govoplan-admin/webui", "resolved": "../../govoplan-admin/webui",
"link": true "link": true
@@ -1066,6 +1107,10 @@
"resolved": "../../govoplan-mail/webui", "resolved": "../../govoplan-mail/webui",
"link": true "link": true
}, },
"node_modules/@govoplan/notifications-webui": {
"resolved": "../../govoplan-notifications/webui",
"link": true
},
"node_modules/@govoplan/ops-webui": { "node_modules/@govoplan/ops-webui": {
"resolved": "../../govoplan-ops/webui", "resolved": "../../govoplan-ops/webui",
"link": true "link": true

View File

@@ -28,6 +28,7 @@
"dependencies": { "dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui", "@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui", "@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui", "@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui", "@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
@@ -36,6 +37,7 @@
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui", "@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui", "@govoplan/policy-webui": "file:../../govoplan-policy/webui",

View File

@@ -24,6 +24,7 @@
"dependencies": { "dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8", "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-addresses.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8", "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8", "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8", "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
@@ -31,6 +32,7 @@
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8", "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8", "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
"@govoplan/notifications-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-notifications.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8", "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8", "@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",

View File

@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
const packageByModule = { const packageByModule = {
access: "@govoplan/access-webui", access: "@govoplan/access-webui",
admin: "@govoplan/admin-webui", admin: "@govoplan/admin-webui",
addresses: "@govoplan/addresses-webui",
audit: "@govoplan/audit-webui", audit: "@govoplan/audit-webui",
campaigns: "@govoplan/campaign-webui", campaigns: "@govoplan/campaign-webui",
dashboard: "@govoplan/dashboard-webui", dashboard: "@govoplan/dashboard-webui",
@@ -20,6 +21,7 @@ const cases = [
{ name: "core-only", modules: [] }, { name: "core-only", modules: [] },
{ name: "access-only", modules: ["access"] }, { name: "access-only", modules: ["access"] },
{ name: "admin-only", modules: ["admin"] }, { name: "admin-only", modules: ["admin"] },
{ name: "addresses-only", modules: ["addresses"] },
{ name: "access-with-admin", modules: ["access", "admin"] }, { name: "access-with-admin", modules: ["access", "admin"] },
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] }, { name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
{ name: "dashboard-only", modules: ["dashboard"] }, { name: "dashboard-only", modules: ["dashboard"] },
@@ -33,7 +35,7 @@ const cases = [
{ name: "scheduling-only", modules: ["scheduling"] }, { name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] }, { name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] }, { name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "scheduling"] } { name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "scheduling"] }
]; ];
const npmExec = process.env.npm_execpath; const npmExec = process.env.npm_execpath;

View File

@@ -1,9 +1,9 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchMe, updateProfile } from "./api/auth"; import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform"; import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client"; import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthTenant, AuthTenantMembership, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types"; import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import AppShell from "./layout/AppShell"; import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage"; import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal"; import LoginModal from "./features/auth/LoginModal";
@@ -47,9 +47,9 @@ export default function App() {
saveApiSettings(next); saveApiSettings(next);
} }
function updateAuth(next: AuthInfo | null, accessToken?: string) { function updateAuth(next: AuthUpdate | null, accessToken?: string) {
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings; const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
setAuth(next ? normalizeAuthInfo(next) : null); setAuth((current) => next ? normalizeAuthInfo(mergeAuthPayload(current, next)) : null);
if (accessToken !== undefined) { if (accessToken !== undefined) {
setSettings(nextSettings); setSettings(nextSettings);
saveApiSettings(nextSettings); saveApiSettings(nextSettings);
@@ -117,9 +117,12 @@ export default function App() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setCheckingSession(true); setCheckingSession(true);
fetchMe(settings).
then((me) => {if (!cancelled) setAuth(normalizeAuthInfo(me));}). async function bootstrapAuth() {
catch(() => { try {
const shellAuth = await fetchShellAuth(settings);
if (!cancelled) setAuth(normalizeAuthInfo(shellAuth));
} catch {
if (!cancelled) { if (!cancelled) {
const cleared = { ...settings, accessToken: "" }; const cleared = { ...settings, accessToken: "" };
setSettings(cleared); setSettings(cleared);
@@ -128,10 +131,12 @@ export default function App() {
setPlatformModules(null); setPlatformModules(null);
setRemoteWebModules([]); setRemoteWebModules([]);
} }
}). } finally {
finally(() => {
if (!cancelled) setCheckingSession(false); if (!cancelled) setCheckingSession(false);
}); }
}
void bootstrapAuth();
return () => {cancelled = true;}; return () => {cancelled = true;};
}, [settings.apiBaseUrl, settings.apiKey]); }, [settings.apiBaseUrl, settings.apiKey]);
@@ -180,11 +185,25 @@ export default function App() {
root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints); root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints);
root.classList.toggle("ui-reduce-motion", preferences.reduce_motion); root.classList.toggle("ui-reduce-motion", preferences.reduce_motion);
root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars); root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars);
if (preferences.theme === "system") {
delete root.dataset.theme; const systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
} else { const applyTheme = () => {
root.dataset.theme = preferences.theme; const resolvedTheme = preferences.theme === "system" ?
systemDarkQuery?.matches ? "dark" : "light" :
preferences.theme;
root.dataset.theme = resolvedTheme;
root.dataset.themePreference = preferences.theme;
};
applyTheme();
if (preferences.theme !== "system" || !systemDarkQuery) {
return undefined;
} }
systemDarkQuery.addEventListener("change", applyTheme);
return () => {
systemDarkQuery.removeEventListener("change", applyTheme);
};
}, [ }, [
auth?.user.ui_preferences?.compact_tables, auth?.user.ui_preferences?.compact_tables,
auth?.user.ui_preferences?.show_inline_help_hints, auth?.user.ui_preferences?.show_inline_help_hints,
@@ -208,8 +227,11 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (!auth) return; if (!auth) return;
const currentAuth = auth;
let cancelled = false;
let inFlight = false; let inFlight = false;
let lastRefreshAt = 0; let lastRefreshAt = 0;
let lastShellRefreshAt = Date.now();
async function refreshVisibleSession() { async function refreshVisibleSession() {
if (document.visibilityState === "hidden" || inFlight) return; if (document.visibilityState === "hidden" || inFlight) return;
@@ -219,7 +241,17 @@ export default function App() {
inFlight = true; inFlight = true;
lastRefreshAt = now; lastRefreshAt = now;
try { try {
setAuth(normalizeAuthInfo(await fetchMe(settings))); const sessionInfo = await fetchSession(settings);
if (cancelled) return;
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
const shellAuth = await fetchShellAuth(settings);
if (cancelled) return;
lastShellRefreshAt = Date.now();
setAuth((current) => current && sessionMatchesAuth(sessionInfo, current)
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
: normalizeAuthInfo(shellAuth));
}
} catch { } catch {
@@ -230,6 +262,7 @@ export default function App() {
window.addEventListener("focus", refreshVisibleSession); window.addEventListener("focus", refreshVisibleSession);
document.addEventListener("visibilitychange", refreshVisibleSession); document.addEventListener("visibilitychange", refreshVisibleSession);
return () => { return () => {
cancelled = true;
window.removeEventListener("focus", refreshVisibleSession); window.removeEventListener("focus", refreshVisibleSession);
document.removeEventListener("visibilitychange", refreshVisibleSession); document.removeEventListener("visibilitychange", refreshVisibleSession);
}; };
@@ -327,18 +360,40 @@ export default function App() {
} }
type AuthPayload = Partial<AuthInfo> & { type AuthPayload = AuthUpdate;
principal?: AuthInfo["principal"];
user?: Partial<AuthUser> | null; function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayload {
tenant?: AuthTenant | null; if (!current) return next;
active_tenant?: AuthTenant | null; const nextActiveTenant = next.active_tenant ?? next.tenant ?? null;
tenants?: AuthTenantMembership[] | null; const currentActiveTenant = current.active_tenant ?? current.tenant;
}; const tenantChanged = Boolean(nextActiveTenant && nextActiveTenant.id !== currentActiveTenant.id);
return {
...current,
...next,
user: next.user ? { ...current.user, ...next.user } : current.user,
tenant: nextActiveTenant ?? current.tenant,
active_tenant: nextActiveTenant ?? currentActiveTenant,
tenants: next.tenants ?? current.tenants,
scopes: next.scopes ?? current.scopes,
roles: next.roles ?? (next.roles_loaded === false || tenantChanged ? [] : current.roles),
groups: next.groups ?? (next.groups_loaded === false || tenantChanged ? [] : current.groups),
principal: next.principal === undefined ? current.principal : next.principal,
available_languages: next.available_languages ?? (tenantChanged ? undefined : current.available_languages),
enabled_language_codes: next.enabled_language_codes ?? (tenantChanged ? undefined : current.enabled_language_codes),
default_language: next.default_language ?? (tenantChanged ? undefined : current.default_language),
profile_loaded: next.profile_loaded ?? (tenantChanged ? false : current.profile_loaded),
roles_loaded: next.roles_loaded ?? (tenantChanged ? false : current.roles_loaded),
groups_loaded: next.groups_loaded ?? (tenantChanged ? false : current.groups_loaded)
};
}
function normalizeAuthInfo(response: AuthPayload): AuthInfo { function normalizeAuthInfo(response: AuthPayload): AuthInfo {
const principal = response.principal ?? null; const principal = response.principal ?? null;
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null; const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
const user = normalizeAuthUser(response.user, principal); const user = normalizeAuthUser(response.user, principal);
const profileLoaded = response.profile_loaded ?? hasFullProfilePayload(response);
const rolesLoaded = response.roles_loaded ?? response.roles !== undefined;
const groupsLoaded = response.groups_loaded ?? response.groups !== undefined;
if (!activeTenant) { if (!activeTenant) {
throw new Error("Authentication response did not include an active tenant."); throw new Error("Authentication response did not include an active tenant.");
@@ -356,12 +411,25 @@ function normalizeAuthInfo(response: AuthPayload): AuthInfo {
roles: response.roles ?? [], roles: response.roles ?? [],
groups: response.groups ?? [], groups: response.groups ?? [],
principal, principal,
available_languages: response.available_languages ?? [], available_languages: response.available_languages,
enabled_language_codes: response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [], enabled_language_codes: profileLoaded ? response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [] : undefined,
default_language: response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en" default_language: profileLoaded ? response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en" : undefined,
profile_loaded: profileLoaded,
roles_loaded: rolesLoaded,
groups_loaded: groupsLoaded
}; };
} }
function hasFullProfilePayload(response: AuthPayload): boolean {
return Boolean(
response.default_language ||
response.available_languages ||
response.enabled_language_codes ||
response.roles ||
response.groups
);
}
function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal: AuthInfo["principal"]): AuthUser | null { function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal: AuthInfo["principal"]): AuthUser | null {
if (user?.id && user.account_id) { if (user?.id && user.account_id) {
return { return {
@@ -396,6 +464,17 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
}; };
} }
function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
const activeTenant = auth.active_tenant ?? auth.tenant;
if (sessionInfo.user.id !== auth.user.id) return false;
if (sessionInfo.user.account_id !== auth.user.account_id) return false;
if ((sessionInfo.active_tenant ?? sessionInfo.tenant).id !== activeTenant.id) return false;
if (auth.principal?.auth_method && sessionInfo.auth_method !== auth.principal.auth_method) return false;
if (auth.principal?.session_id && sessionInfo.session_id && auth.principal.session_id !== sessionInfo.session_id) return false;
if (auth.principal?.api_key_id && sessionInfo.api_key_id && auth.principal.api_key_id !== sessionInfo.api_key_id) return false;
return true;
}
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences { function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system"; const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
return { return {

View File

@@ -0,0 +1,56 @@
import type { ApiSettings } from "../types";
import { apiFetch, apiGetList } from "./client";
export type PermissionItem = {
scope: string;
label: string;
description: string;
category: string;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
return apiGetList<PermissionItem, "permissions">(settings, "/api/v1/admin/permissions", "permissions");
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
return apiGetList<TenantAdminItem, "tenants">(settings, "/api/v1/admin/tenants", "tenants");
}

View File

@@ -1,4 +1,4 @@
import type { ApiSettings, AuthInfo, LoginResponse, UserUiPreferences } from "../types"; import type { ApiSettings, AuthGroupsInfo, AuthInfo, AuthProfileInfo, AuthRolesInfo, AuthSessionInfo, AuthShellInfo, LoginResponse, UserUiPreferences } from "../types";
import { apiFetch } from "./client"; import { apiFetch } from "./client";
export async function login( export async function login(
@@ -15,8 +15,28 @@ export async function fetchMe(settings: ApiSettings): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/me"); return apiFetch<AuthInfo>(settings, "/api/v1/auth/me");
} }
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthInfo> { export async function fetchSession(settings: ApiSettings): Promise<AuthSessionInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/switch-tenant", { return apiFetch<AuthSessionInfo>(settings, "/api/v1/auth/session", { cache: "no-store" });
}
export async function fetchShellAuth(settings: ApiSettings): Promise<AuthShellInfo> {
return apiFetch<AuthShellInfo>(settings, "/api/v1/auth/shell", { cache: "no-store" });
}
export async function fetchAuthProfile(settings: ApiSettings): Promise<AuthProfileInfo> {
return apiFetch<AuthProfileInfo>(settings, "/api/v1/auth/profile", { cache: "no-store" });
}
export async function fetchAuthRoles(settings: ApiSettings): Promise<AuthRolesInfo> {
return apiFetch<AuthRolesInfo>(settings, "/api/v1/auth/roles", { cache: "no-store" });
}
export async function fetchAuthGroups(settings: ApiSettings): Promise<AuthGroupsInfo> {
return apiFetch<AuthGroupsInfo>(settings, "/api/v1/auth/groups", { cache: "no-store" });
}
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthShellInfo> {
return apiFetch<AuthShellInfo>(settings, "/api/v1/auth/switch-tenant", {
method: "POST", method: "POST",
body: JSON.stringify({ tenant_id: tenantId }) body: JSON.stringify({ tenant_id: tenantId })
}); });
@@ -35,8 +55,8 @@ export async function updateProfile(
enabled_language_codes?: string[] | null; enabled_language_codes?: string[] | null;
ui_preferences?: Partial<UserUiPreferences> | null; ui_preferences?: Partial<UserUiPreferences> | null;
} }
): Promise<AuthInfo> { ): Promise<AuthProfileInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/profile", { return apiFetch<AuthProfileInfo>(settings, "/api/v1/auth/profile", {
method: "PATCH", method: "PATCH",
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });

View File

@@ -1,9 +1,9 @@
import type { ApiSettings } from "../types"; import type { ApiSettings } from "../types";
const STORAGE_KEY = "multimailer.apiSettings"; const STORAGE_KEY = "govoplan.apiSettings";
const LEGACY_STORAGE_KEYS = ["i18n:govoplan-core.multimailer_apisettings.1d1601d4"]; const LEGACY_STORAGE_KEYS: string[] = [];
const SESSION_STORAGE_KEY = "multimailer.session"; const SESSION_STORAGE_KEY = "govoplan.session";
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf"; const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "govoplan_csrf";
const RECENT_SAFE_REQUEST_TTL_MS = 750; const RECENT_SAFE_REQUEST_TTL_MS = 750;
const MAX_RECENT_SAFE_REQUESTS = 100; const MAX_RECENT_SAFE_REQUESTS = 100;
const MAX_CONDITIONAL_SAFE_REQUESTS = 200; const MAX_CONDITIONAL_SAFE_REQUESTS = 200;
@@ -77,6 +77,56 @@ export function apiUrl(settings: ApiSettings, path: string): string {
return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath; return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath;
} }
export type ApiQueryValue = string | number | boolean | null | undefined;
export type ApiQueryParams = Record<string, ApiQueryValue | readonly ApiQueryValue[]>;
function queryValue(value: ApiQueryValue): string | null {
if (value === null || value === undefined || value === "") return null;
return String(value);
}
export function apiQuery(params: ApiQueryParams = {}): string {
const search = new URLSearchParams();
for (const [key, rawValue] of Object.entries(params)) {
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
for (const value of values) {
const normalized = queryValue(value);
if (normalized !== null) search.append(key, normalized);
}
}
const query = search.toString();
return query ? `?${query}` : "";
}
export function apiPath(path: string, params: ApiQueryParams = {}): string {
const query = apiQuery(params);
if (!query) return path;
return path.includes("?") ? `${path}&${query.slice(1)}` : `${path}${query}`;
}
export async function apiGetList<TItem, K extends string>(
settings: ApiSettings,
path: string,
key: K,
params: ApiQueryParams = {}
): Promise<TItem[]> {
const response = await apiFetch<Record<K, TItem[] | null | undefined>>(settings, apiPath(path, params));
return response[key] ?? [];
}
export function apiPost<TResponse>(settings: ApiSettings, path: string, init: Omit<RequestInit, "method"> = {}): Promise<TResponse> {
return apiFetch<TResponse>(settings, path, { ...init, method: "POST" });
}
export function apiPostJson<TResponse, TPayload = unknown>(
settings: ApiSettings,
path: string,
payload: TPayload,
init: Omit<RequestInit, "method" | "body"> = {}
): Promise<TResponse> {
return apiPost<TResponse>(settings, path, { ...init, body: JSON.stringify(payload) });
}
export function loadApiSettings(): ApiSettings { export function loadApiSettings(): ApiSettings {
const storedBaseUrl = loadStoredSetting("baseUrl"); const storedBaseUrl = loadStoredSetting("baseUrl");
const storedApiKey = sessionStorage.getItem(`${SESSION_STORAGE_KEY}.apiKey`); const storedApiKey = sessionStorage.getItem(`${SESSION_STORAGE_KEY}.apiKey`);

View File

@@ -0,0 +1,143 @@
import type {
MailImapTransportSettings,
MailProfilePatternKey,
MailProfilePolicy,
MailProfileScope,
MailSecurity,
MailServerProfile,
MailServerProfileCredentials,
MailTransportCredentials,
MailTransportSettings
} from "../types";
export type {
MailCredentialPolicy,
MailProfilePatternKey,
MailProfilePolicy,
MailProfileScope,
MailSecurity,
MailServerProfile
} from "../types";
export type MailSmtpTestPayload = MailTransportSettings;
export type MailImapTestPayload = MailImapTransportSettings;
export type MailTransportCredentialsPayload = MailTransportCredentials;
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
details?: Record<string, unknown>;
};
export type MailImapFolderResponse = {
name: string;
flags?: string[];
message_count?: number | null;
unseen_count?: number | null;
};
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
folders: MailImapFolderResponse[];
detected_sent_folder?: string | null;
from_cache?: boolean;
refreshing?: boolean;
indexed_at?: string | null;
details?: Record<string, unknown>;
};
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
] as const satisfies readonly MailProfilePatternKey[];
export const mailProfilePolicyLimitKeys = [
"allowed_profile_ids",
"allow_user_profiles",
"allow_group_profiles",
"smtp_credentials.inherit",
"imap_credentials.inherit",
"whitelist.smtp_hosts",
"whitelist.imap_hosts",
"whitelist.envelope_senders",
"whitelist.from_headers",
"whitelist.recipient_domains",
"blacklist.smtp_hosts",
"blacklist.imap_hosts",
"blacklist.envelope_senders",
"blacklist.from_headers",
"blacklist.recipient_domains"
] as const;
export type MailProfilePolicyLimitKey = typeof mailProfilePolicyLimitKeys[number];
export type MailProfilePolicyLimitPermissions = Partial<Record<MailProfilePolicyLimitKey, boolean>>;
export type MailPolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
policy?: MailProfilePolicy | null;
};
export type MailProfilePolicyResponse = {
scope_type: MailProfileScope;
scope_id?: string | null;
policy: MailProfilePolicy;
effective_policy?: MailProfilePolicy | null;
parent_policy?: MailProfilePolicy | null;
effective_policy_sources?: MailPolicySourceStep[];
parent_policy_sources?: MailPolicySourceStep[];
};
export type MailServerProfilePayload = {
name: string;
slug?: string | null;
description?: string | null;
is_active?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
};
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};

View File

@@ -1,5 +1,5 @@
import type { ApiSettings } from "../types"; import type { ApiSettings } from "../types";
import { apiFetch } from "./client"; import { apiFetch, apiPath } from "./client";
export type PrivacyRetentionPolicyFieldKey = export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json" | "store_raw_campaign_json"
@@ -78,13 +78,6 @@ export type RetentionRunResponse = {
}; };
}; };
function retentionPolicyQuery(scopeId?: string | null): string {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString();
return suffix ? `?${suffix}` : "";
}
export function getPrivacyRetentionPolicy( export function getPrivacyRetentionPolicy(
settings: ApiSettings, settings: ApiSettings,
scope: PrivacyRetentionPolicyScope, scope: PrivacyRetentionPolicyScope,
@@ -92,7 +85,7 @@ export function getPrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyScopeResponse> { ): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch( return apiFetch(
settings, settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}` apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}`, { scope_id: scopeId })
); );
} }
@@ -103,7 +96,7 @@ export function explainPrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyExplainResponse> { ): Promise<PrivacyRetentionPolicyExplainResponse> {
return apiFetch( return apiFetch(
settings, settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain${retentionPolicyQuery(scopeId)}` apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain`, { scope_id: scopeId })
); );
} }
@@ -116,7 +109,7 @@ export function updatePrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyScopeResponse> { ): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch( return apiFetch(
settings, settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`, apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}`, { scope_id: scopeId }),
{ method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) } { method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) }
); );
} }

View File

@@ -0,0 +1,103 @@
import type { ReactNode } from "react";
import FormField from "./FormField";
import PasswordField from "./PasswordField";
export type CredentialValues = {
username?: string | null;
password?: string | null;
};
export type CredentialFieldsProps = {
values: CredentialValues;
onChange: (patch: Partial<CredentialValues>) => void;
disabled?: boolean;
usernameDisabled?: boolean;
passwordDisabled?: boolean;
savedPassword?: boolean;
savedPasswordPlaceholder?: string;
heading?: ReactNode;
headingClassName?: string;
usernameLabel?: ReactNode;
passwordLabel?: ReactNode;
usernamePlaceholder?: string;
passwordPlaceholder?: string;
usernameAutoComplete?: string;
passwordAutoComplete?: string;
showUsername?: boolean;
showPassword?: boolean;
};
export type CredentialPanelProps = CredentialFieldsProps & {
className?: string;
gridClassName?: string;
children?: ReactNode;
};
export function CredentialFields({
values,
onChange,
disabled = false,
usernameDisabled = disabled,
passwordDisabled = disabled,
savedPassword = false,
savedPasswordPlaceholder = "••••••••",
heading,
headingClassName = "credential-panel-heading",
usernameLabel = "i18n:govoplan-core.username.84c29015",
passwordLabel = "i18n:govoplan-core.password.8be3c943",
usernamePlaceholder,
passwordPlaceholder,
usernameAutoComplete = "username",
passwordAutoComplete = "new-password",
showUsername = true,
showPassword = true
}: CredentialFieldsProps) {
return (
<>
{heading && <div className={headingClassName}>{heading}</div>}
{showUsername &&
<FormField label={usernameLabel}>
<input
value={stringValue(values.username)}
disabled={usernameDisabled}
autoComplete={usernameAutoComplete}
placeholder={usernamePlaceholder}
onChange={(event) => onChange({ username: event.target.value })} />
</FormField>
}
{showPassword &&
<FormField label={passwordLabel}>
<PasswordField
value={stringValue(values.password)}
disabled={passwordDisabled}
saved={savedPassword}
savedPlaceholder={savedPasswordPlaceholder}
placeholder={passwordPlaceholder}
autoComplete={passwordAutoComplete}
onValueChange={(password) => onChange({ password })} />
</FormField>
}
</>);
}
export default function CredentialPanel({
className = "",
gridClassName = "",
children,
...fieldProps
}: CredentialPanelProps) {
return (
<div className={`credential-panel ${className}`.trim()}>
<div className={`credential-panel-grid ${gridClassName}`.trim()}>
<CredentialFields {...fieldProps} />
</div>
{children && <div className="credential-panel-extra">{children}</div>}
</div>);
}
function stringValue(value: string | number | null | undefined): string {
if (value === null || value === undefined) return "";
return String(value);
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import HoverTooltip from "./HoverTooltip";
export type DisabledActionTooltipProps = {
reason?: ReactNode;
children: ReactNode;
className?: string;
};
export default function DisabledActionTooltip({ reason, children, className = "" }: DisabledActionTooltipProps) {
if (!reason) return <>{children}</>;
return (
<HoverTooltip
content={reason}
tone="danger"
className={`disabled-action-tooltip ${className}`.trim()}
triggerTabIndex={0}>
{children}
</HoverTooltip>
);
}

View File

@@ -0,0 +1,228 @@
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export type HoverTooltipTone = "default" | "danger";
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: "top" | "bottom";
};
export type HoverTooltipProps = {
content: ReactNode;
children: ReactNode;
className?: string;
tone?: HoverTooltipTone;
ariaLabel?: string;
triggerTabIndex?: number;
openDelayMs?: number;
};
const VIEWPORT_MARGIN = 12;
const TRIGGER_GAP = 10;
const BASE_TOOLTIP_STYLE: CSSProperties = {
position: "fixed",
zIndex: 20000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
borderRadius: 7,
boxShadow: "var(--shadow-popover)",
fontSize: 12,
lineHeight: 1.4,
padding: "9px 10px",
whiteSpace: "normal",
pointerEvents: "none"
};
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
function tooltipStyleForTone(tone: HoverTooltipTone): CSSProperties {
if (tone === "danger") {
return {
border: "1px solid var(--border-danger-soft)",
background: "var(--danger-muted-bg)",
color: "var(--danger-text-tooltip)",
fontWeight: 700
};
}
return {
border: "1px solid var(--line-dark)",
background: "var(--surface)",
color: "var(--text)",
fontWeight: 500
};
}
function arrowStyleForTone(tone: HoverTooltipTone): Pick<CSSProperties, "borderColor" | "background"> {
if (tone === "danger") {
return {
borderColor: "var(--border-danger-soft)",
background: "var(--danger-muted-bg)"
};
}
return {
borderColor: "var(--line-dark)",
background: "var(--surface)"
};
}
export default function HoverTooltip({
content,
children,
className = "",
tone = "default",
ariaLabel,
triggerTabIndex,
openDelayMs = 350
}: HoverTooltipProps) {
const { translateText } = usePlatformLanguage();
const tooltipId = useId();
const triggerRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const openWithDelay = useCallback(() => {
if (!content) return;
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
setIsOpen(true);
}, openDelayMs);
}, [clearOpenTimer, content, openDelayMs]);
const close = useCallback(() => {
clearOpenTimer();
setIsOpen(false);
}, [clearOpenTimer]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
const top = hasRoomAbove
? topCandidate
: clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
setPosition({
top,
left,
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
placement: hasRoomAbove ? "top" : "bottom"
});
}, []);
useLayoutEffect(() => {
if (!isOpen) {
setPosition(null);
return;
}
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return undefined;
const handleScrollOrResize = () => updatePosition();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("keydown", handleKeyDown);
};
}, [close, isOpen, updatePosition]);
useEffect(() => clearOpenTimer, [clearOpenTimer]);
const translatedAriaLabel = ariaLabel ? translateText(ariaLabel) : undefined;
const tooltipStyle: CSSProperties = {
...BASE_TOOLTIP_STYLE,
...tooltipStyleForTone(tone),
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: position ? 1 : 0
};
const arrowColors = arrowStyleForTone(tone);
const arrowStyle: CSSProperties = position?.placement === "bottom"
? {
position: "absolute",
left: position.arrowLeft,
top: 0,
width: 9,
height: 9,
borderLeft: "1px solid",
borderTop: "1px solid",
...arrowColors,
transform: "translate(-50%, -5px) rotate(45deg)"
}
: {
position: "absolute",
left: position?.arrowLeft ?? 16,
top: "100%",
width: 9,
height: 9,
borderRight: "1px solid",
borderBottom: "1px solid",
...arrowColors,
transform: "translate(-50%, -5px) rotate(45deg)"
};
return (
<>
<span
ref={triggerRef}
className={className}
tabIndex={triggerTabIndex}
aria-label={translatedAriaLabel}
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={openWithDelay}
onMouseLeave={close}
onFocus={openWithDelay}
onBlur={close}>
{children}
</span>
{isOpen && typeof document !== "undefined" && createPortal(
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{typeof content === "string" ? translateText(content) : content}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>
);
}

View File

@@ -1,4 +1,5 @@
import { i18nMessage } from "../../i18n/LanguageContext";import { useEffect, useId, useMemo, useRef, useState } from "react"; import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent } from "react"; import type { CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Button from "../Button"; import Button from "../Button";
@@ -15,6 +16,7 @@ type EmailAddressInputProps = {
value: MailboxAddress[]; value: MailboxAddress[];
onChange?: (value: MailboxAddress[]) => void; onChange?: (value: MailboxAddress[]) => void;
onAddressAdded?: (address: MailboxAddress) => void; onAddressAdded?: (address: MailboxAddress) => void;
onSuggestionQueryChange?: (query: string) => void;
suggestions?: MailboxAddress[]; suggestions?: MailboxAddress[];
allowMultiple?: boolean; allowMultiple?: boolean;
clearOnAdd?: boolean; clearOnAdd?: boolean;
@@ -31,6 +33,7 @@ export default function EmailAddressInput({
value, value,
onChange, onChange,
onAddressAdded, onAddressAdded,
onSuggestionQueryChange,
suggestions = [], suggestions = [],
allowMultiple = true, allowMultiple = true,
clearOnAdd = false, clearOnAdd = false,
@@ -42,6 +45,7 @@ export default function EmailAddressInput({
compact = false, compact = false,
showAddButton showAddButton
}: EmailAddressInputProps) { }: EmailAddressInputProps) {
const { translateText } = usePlatformLanguage();
const inputId = useId(); const inputId = useId();
const normalizedValue = useMemo(() => dedupeAddresses(value), [value]); const normalizedValue = useMemo(() => dedupeAddresses(value), [value]);
const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]); const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]);
@@ -51,9 +55,18 @@ export default function EmailAddressInput({
const [dialogEmail, setDialogEmail] = useState(""); const [dialogEmail, setDialogEmail] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({}); const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const lastSuggestionQueryRef = useRef<string | null>(null);
const addButtonRef = useRef<HTMLButtonElement | null>(null); const addButtonRef = useRef<HTMLButtonElement | null>(null);
const canUseAddButton = showAddButton ?? allowMultiple; const canUseAddButton = showAddButton ?? allowMultiple;
useEffect(() => {
const query = entryText.trim();
if (query === "" && lastSuggestionQueryRef.current === null) return;
if (query === lastSuggestionQueryRef.current) return;
lastSuggestionQueryRef.current = query;
onSuggestionQueryChange?.(query);
}, [entryText, onSuggestionQueryChange]);
const filteredSuggestions = useMemo(() => { const filteredSuggestions = useMemo(() => {
const query = entryText.trim().toLowerCase(); const query = entryText.trim().toLowerCase();
if (!query) return normalizedSuggestions.slice(0, 6); if (!query) return normalizedSuggestions.slice(0, 6);
@@ -168,18 +181,18 @@ export default function EmailAddressInput({
if (event.key === "Escape") setDialogOpen(false); if (event.key === "Escape") setDialogOpen(false);
}}> }}>
<h4 id={`${inputId}-dialog-title`}>i18n:govoplan-core.add_address.a71075c4</h4> <h4 id={`${inputId}-dialog-title`}>{translateText("i18n:govoplan-core.add_address.a71075c4")}</h4>
<label> <label>
<span>i18n:govoplan-core.name.709a2322</span> <span>{translateText("i18n:govoplan-core.name.709a2322")}</span>
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus /> <input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={translateText(namePlaceholder)} autoFocus />
</label> </label>
<label> <label>
<span>i18n:govoplan-core.email_address.c94d3175</span> <span>{translateText("i18n:govoplan-core.email_address.c94d3175")}</span>
<input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" /> <input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" />
</label> </label>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button type="button" onClick={() => setDialogOpen(false)}>i18n:govoplan-core.cancel.77dfd213</Button> <Button type="button" onClick={() => setDialogOpen(false)}>{translateText("i18n:govoplan-core.cancel.77dfd213")}</Button>
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button> <Button type="button" variant="primary" onClick={commitDialogAddress}>{translateText(addLabel)}</Button>
</div> </div>
</div>, </div>,
document.body document.body
@@ -189,11 +202,11 @@ export default function EmailAddressInput({
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}> <div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}>
<div className={`email-address-editor ${error ? "has-error" : ""}`}> <div className={`email-address-editor ${error ? "has-error" : ""}`}>
<div className="email-chip-list" aria-live="polite"> <div className="email-chip-list" aria-live="polite">
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{emptyText}</span>} {normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
{normalizedValue.map((address) => { {normalizedValue.map((address) => {
const valid = isValidEmailAddress(address.email); const valid = isValidEmailAddress(address.email);
return ( return (
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "i18n:govoplan-core.invalid_email_address.9e4ee6d7"}> <span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : translateText("i18n:govoplan-core.invalid_email_address.9e4ee6d7")}>
<span className="email-chip-main">{addressDisplayName(address)}</span> <span className="email-chip-main">{addressDisplayName(address)}</span>
{address.name && <span className="email-chip-address">{address.email}</span>} {address.name && <span className="email-chip-address">{address.email}</span>}
{!disabled && {!disabled &&
@@ -217,11 +230,11 @@ export default function EmailAddressInput({
setError(""); setError("");
}} }}
onKeyDown={handleTextKeyDown} onKeyDown={handleTextKeyDown}
placeholder={i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: namePlaceholder, value1: emailPlaceholder })} placeholder={translateText(i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: translateText(namePlaceholder), value1: emailPlaceholder }))}
aria-label="i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0" /> aria-label={translateText("i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0")} />
{canUseAddButton && {canUseAddButton &&
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="i18n:govoplan-core.open_address_form.f8ee560f" title="i18n:govoplan-core.add_address_with_form.13b3b3e7" onClick={() => setDialogOpen((open) => !open)}> <button ref={addButtonRef} type="button" className="email-address-plus" aria-label={translateText("i18n:govoplan-core.open_address_form.f8ee560f")} title={translateText("i18n:govoplan-core.add_address_with_form.13b3b3e7")} onClick={() => setDialogOpen((open) => !open)}>
+ +
</button> </button>
} }
@@ -230,7 +243,7 @@ export default function EmailAddressInput({
</div> </div>
{!disabled && filteredSuggestions.length > 0 && entryText.trim() && {!disabled && filteredSuggestions.length > 0 && entryText.trim() &&
<div className="email-address-suggestions" role="listbox" aria-label="i18n:govoplan-core.address_suggestions.45ba4a20"> <div className="email-address-suggestions" role="listbox" aria-label={translateText("i18n:govoplan-core.address_suggestions.45ba4a20")}>
{filteredSuggestions.map((item) => {filteredSuggestions.map((item) =>
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option"> <button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
<span>{addressDisplayName(item)}</span> <span>{addressDisplayName(item)}</span>
@@ -239,7 +252,7 @@ export default function EmailAddressInput({
)} )}
</div> </div>
} }
{error && <p className="form-help danger-text">{error}</p>} {error && <p className="form-help danger-text">{translateText(error)}</p>}
{addressDialog} {addressDialog}
</div>); </div>);

View File

@@ -1,188 +1,20 @@
import type { CSSProperties, ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import HoverTooltip from "../HoverTooltip";
import { createPortal } from "react-dom";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
type InlineHelpProps = { type InlineHelpProps = {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}; };
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: "top" | "bottom";
};
const OPEN_DELAY_MS = 350;
const VIEWPORT_MARGIN = 12;
const TRIGGER_GAP = 10;
const tooltipBaseStyle: CSSProperties = {
position: "fixed",
zIndex: 20000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
border: "1px solid var(--line-dark)",
borderRadius: 7,
background: "var(--surface)",
boxShadow: "var(--shadow-popover)",
color: "var(--text)",
fontSize: 12,
fontWeight: 500,
lineHeight: 1.4,
padding: "9px 10px",
whiteSpace: "normal",
pointerEvents: "none"
};
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
export default function InlineHelp({ children, className = "" }: InlineHelpProps) { export default function InlineHelp({ children, className = "" }: InlineHelpProps) {
const { translateText } = usePlatformLanguage();
const tooltipId = useId();
const triggerRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const openWithDelay = useCallback(() => {
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
setIsOpen(true);
}, OPEN_DELAY_MS);
}, [clearOpenTimer]);
const close = useCallback(() => {
clearOpenTimer();
setIsOpen(false);
}, [clearOpenTimer]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
const top = hasRoomAbove ?
topCandidate :
clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
setPosition({
top,
left,
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
placement: hasRoomAbove ? "top" : "bottom"
});
}, []);
useLayoutEffect(() => {
if (!isOpen) {
setPosition(null);
return;
}
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return undefined;
const handleScrollOrResize = () => updatePosition();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("keydown", handleKeyDown);
};
}, [close, isOpen, updatePosition]);
useEffect(() => clearOpenTimer, [clearOpenTimer]);
if (!children) return null; if (!children) return null;
const tooltipStyle: CSSProperties = {
...tooltipBaseStyle,
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: position ? 1 : 0
};
const arrowStyle: CSSProperties = position?.placement === "bottom" ?
{
position: "absolute",
left: position.arrowLeft,
top: 0,
width: 9,
height: 9,
borderLeft: "1px solid var(--line-dark)",
borderTop: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
} :
{
position: "absolute",
left: position?.arrowLeft ?? 16,
top: "100%",
width: 9,
height: 9,
borderRight: "1px solid var(--line-dark)",
borderBottom: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
};
return ( return (
<> <HoverTooltip
<span content={children}
ref={triggerRef}
className={`inline-help ${className}`.trim()} className={`inline-help ${className}`.trim()}
tabIndex={-1} ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f"
aria-label={translateText("i18n:govoplan-core.show_field_help.e3dfe98f")} triggerTabIndex={-1}>
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={openWithDelay}
onMouseLeave={close}
onFocus={openWithDelay}
onBlur={close}>
<span className="inline-help-mark" aria-hidden="true">?</span> <span className="inline-help-mark" aria-hidden="true">?</span>
</span> </HoverTooltip>
{isOpen && createPortal( );
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{typeof children === "string" ? translateText(children) : children}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>);
} }

View File

@@ -1,8 +1,8 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import Button from "../Button"; import Button from "../Button";
import { CredentialFields } from "../CredentialPanel";
import DismissibleAlert from "../DismissibleAlert"; import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField"; import FormField from "../FormField";
import PasswordField from "../PasswordField";
import SegmentedControl from "../SegmentedControl"; import SegmentedControl from "../SegmentedControl";
import ToggleSwitch from "../ToggleSwitch"; import ToggleSwitch from "../ToggleSwitch";
@@ -48,6 +48,9 @@ export type MailServerFolderLookupResult = {
details?: Record<string, unknown> | null; details?: Record<string, unknown> | null;
}; };
export type MailServerSettingsSection = "smtp" | "imap" | "advanced";
export type MailServerSettingsMode = "all" | "server" | "credentials";
export type MailServerSettingsPanelProps = { export type MailServerSettingsPanelProps = {
smtp: MailServerSmtpSettings; smtp: MailServerSmtpSettings;
imap: MailServerImapSettings; imap: MailServerImapSettings;
@@ -97,12 +100,13 @@ export type MailServerSettingsPanelProps = {
className?: string; className?: string;
floatingResults?: boolean; floatingResults?: boolean;
initialSection?: MailServerSettingsSection; initialSection?: MailServerSettingsSection;
visibleSections?: readonly MailServerSettingsSection[];
mode?: MailServerSettingsMode;
}; };
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const; export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number]; export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
const securityOptions = mailServerSecurityOptions; const securityOptions = mailServerSecurityOptions;
type MailServerSettingsSection = "smtp" | "imap" | "advanced";
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number { export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
if (security === "tls") return 465; if (security === "tls") return 465;
@@ -234,7 +238,9 @@ export default function MailServerSettingsPanel({
disabled = false, disabled = false,
className = "", className = "",
floatingResults = false, floatingResults = false,
initialSection = "smtp" initialSection = "smtp",
visibleSections,
mode = "all"
}: MailServerSettingsPanelProps) { }: MailServerSettingsPanelProps) {
const smtpFieldsDisabled = disabled || smtpDisabled; const smtpFieldsDisabled = disabled || smtpDisabled;
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled; const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
@@ -254,11 +260,29 @@ export default function MailServerSettingsPanel({
const appendTargetHelp = append ? const appendTargetHelp = append ?
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" : "i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" :
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e"; "i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e";
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(initialSection); const allSections: {id: MailServerSettingsSection;label: string;}[] = [
const sections: {id: MailServerSettingsSection;label: string;}[] = [
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" }, { id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" }, { id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" },
{ id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }]; { id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }];
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
const fallbackSection = sections[0]?.id ?? "smtp";
const resolvedInitialSection = sections.some((section) => section.id === initialSection) ? initialSection : fallbackSection;
const sectionKey = sections.map((section) => section.id).join("|");
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
const showSectionSwitcher = sections.length > 1;
const showServerFields = mode !== "credentials";
const showCredentialFields = mode !== "server";
useEffect(() => {
if (!sections.some((section) => section.id === activeSection)) {
setActiveSection(resolvedInitialSection);
return;
}
if (initialSection !== activeSection && sections.some((section) => section.id === initialSection)) {
setActiveSection(initialSection);
}
}, [activeSection, initialSection, resolvedInitialSection, sectionKey]);
function patchSmtpSecurity(security: MailServerSecurity) { function patchSmtpSecurity(security: MailServerSecurity) {
@@ -283,6 +307,7 @@ export default function MailServerSettingsPanel({
return ( return (
<div className={`mail-server-settings-panel ${className}`.trim()}> <div className={`mail-server-settings-panel ${className}`.trim()}>
{showSectionSwitcher &&
<SegmentedControl <SegmentedControl
className="mail-server-segmented-control" className="mail-server-segmented-control"
size="equal" size="equal"
@@ -291,27 +316,30 @@ export default function MailServerSettingsPanel({
onChange={setActiveSection} onChange={setActiveSection}
options={sections} options={sections}
/> />
}
<div className="mail-server-settings-view"> <div className="mail-server-settings-view">
{activeSection === "smtp" && {activeSection === "smtp" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570"> <section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570">
<div className="form-grid compact responsive-form-grid mail-server-form-grid"> <div className="form-grid compact responsive-form-grid mail-server-form-grid">
{showServerFields &&
<>
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div> <div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField> <FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField> <FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField> <FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div> </>
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField> }
<FormField label="i18n:govoplan-core.password.8be3c943"> {showCredentialFields &&
<PasswordField <CredentialFields
value={stringValue(smtpCredentialValues.password)} values={smtpCredentialValues}
onChange={patchSmtpCredentials}
disabled={smtpCredentialFieldsDisabled} disabled={smtpCredentialFieldsDisabled}
saved={smtpPasswordSaved} savedPassword={smtpPasswordSaved}
savedPlaceholder={smtpSavedPasswordPlaceholder} savedPasswordPlaceholder={smtpSavedPasswordPlaceholder}
autoComplete="new-password" heading="i18n:govoplan-core.credentials.dd097a22"
onValueChange={(password) => patchSmtpCredentials({ password })} /> headingClassName="mail-server-field-heading" />
}
</FormField>
</div> </div>
{onTestSmtp && {onTestSmtp &&
<div className="button-row compact-actions mail-server-actions"> <div className="button-row compact-actions mail-server-actions">
@@ -325,22 +353,24 @@ export default function MailServerSettingsPanel({
{activeSection === "imap" && {activeSection === "imap" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247"> <section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247">
<div className="form-grid compact responsive-form-grid mail-server-form-grid"> <div className="form-grid compact responsive-form-grid mail-server-form-grid">
{showServerFields &&
<>
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div> <div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField> <FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField> <FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField> <FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div> </>
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField> }
<FormField label="i18n:govoplan-core.password.8be3c943"> {showCredentialFields &&
<PasswordField <CredentialFields
value={stringValue(imapCredentialValues.password)} values={imapCredentialValues}
onChange={patchImapCredentials}
disabled={imapCredentialFieldsDisabled} disabled={imapCredentialFieldsDisabled}
saved={imapPasswordSaved} savedPassword={imapPasswordSaved}
savedPlaceholder={imapSavedPasswordPlaceholder} savedPasswordPlaceholder={imapSavedPasswordPlaceholder}
autoComplete="new-password" heading="i18n:govoplan-core.credentials.dd097a22"
onValueChange={(password) => patchImapCredentials({ password })} /> headingClassName="mail-server-field-heading" />
}
</FormField>
</div> </div>
{onTestImap && {onTestImap &&
<div className="button-row compact-actions mail-server-actions"> <div className="button-row compact-actions mail-server-actions">

View File

@@ -150,7 +150,7 @@ type ColumnResizeState = {
behavior: DataGridResizeBehavior; behavior: DataGridResizeBehavior;
}; };
const STORAGE_PREFIX = "multimailer.datagrid."; const STORAGE_PREFIX = "govoplan.datagrid.";
const FILTER_POPOVER_WIDTH = 320; const FILTER_POPOVER_WIDTH = 320;
const FILTER_POPOVER_MARGIN = 12; const FILTER_POPOVER_MARGIN = 12;
const MIN_HEADER_LABEL_WIDTH = 72; const MIN_HEADER_LABEL_WIDTH = 72;

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import type { ApiSettings, AuthInfo, FilesConnectorsUiCapability, MailProfilesUiCapability, UserUiPreferences, UserUiTheme } from "../../types"; import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
import Card from "../../components/Card"; import Card from "../../components/Card";
import FormField from "../../components/FormField"; import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField"; import PasswordField from "../../components/PasswordField";
@@ -8,15 +8,16 @@ import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle"; import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch"; import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client"; import { apiFetch } from "../../api/client";
import { updateProfile } from "../../api/auth"; import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav"; import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
import SegmentedControl from "../../components/SegmentedControl";
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard"; import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
import { usePlatformUiCapability } from "../../platform/ModuleContext"; import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
import { hasAnyScope, hasScope } from "../../utils/permissions"; import { hasAnyScope, hasScope } from "../../utils/permissions";
import { usePlatformLanguage } from "../../i18n/LanguageContext"; import { usePlatformLanguage } from "../../i18n/LanguageContext";
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | "notifications"; type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
const DEFAULT_UI_PREFERENCES: UserUiPreferences = { const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
compact_tables: false, compact_tables: false,
@@ -32,8 +33,8 @@ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" } { value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
]; ];
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean): ModuleSubnavGroup<SettingsSection>[] { function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
return [ const groups: ModuleSubnavGroup<SettingsSection>[] = [
{ {
title: "i18n:govoplan-core.account.f967543b", title: "i18n:govoplan-core.account.f967543b",
items: [ items: [
@@ -47,11 +48,29 @@ function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boole
items: [ items: [
{ id: "interface", label: "i18n:govoplan-core.interface.7b4db7ef" }, { id: "interface", label: "i18n:govoplan-core.interface.7b4db7ef" },
{ id: "workspace", label: "i18n:govoplan-core.workspace.4ca0a75c" }, { id: "workspace", label: "i18n:govoplan-core.workspace.4ca0a75c" },
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" }, { id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" }]
{ id: "notifications", label: "i18n:govoplan-core.notifications.753a22b2" }]
}]; }];
for (const section of contributedSections) {
const groupId = section.group || "ui";
const title = section.groupTitle || (groupId === "account" ? "i18n:govoplan-core.account.f967543b" : groupId === "ui" ? "i18n:govoplan-core.ui_settings.9e9cc5ea" : groupId);
let group = groups.find((item) => item.title === title);
if (!group) {
group = { title, items: [] };
groups.push(group);
}
group.items.push({ id: section.id, label: section.label });
}
for (const group of groups) {
group.items.sort((left, right) => {
const leftId = "id" in left ? left.id : "";
const rightId = "id" in right ? right.id : "";
return (sectionOrder(leftId, contributedSections) - sectionOrder(rightId, contributedSections)) || left.label.localeCompare(right.label);
});
}
return groups;
} }
export default function SettingsPage({ export default function SettingsPage({
@@ -64,19 +83,26 @@ export default function SettingsPage({
}: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;}) { }: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;}) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { requestNavigation } = useUnsavedChanges(); const { requestNavigation } = useUnsavedChanges();
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles"); const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors"); const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage(); const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null; const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null; const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]); const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]); const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors), [canUseFileConnectors, canUseMailProfiles]); const contributedSections = useMemo(
const requestedSection = searchParams.get("section") as SettingsSection | null; () => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
const active: SettingsSection = settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface"; [auth, settingsSectionCapabilities]
);
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, contributedSections), [canUseFileConnectors, canUseMailProfiles, contributedSections]);
const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
const requestedSection = searchParams.get("section");
const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface";
const activeContributedSection = contributedSections.find((section) => section.id === active) ?? null;
const currentUiPreferences = normalizeUiPreferences(auth.user.ui_preferences); const currentUiPreferences = normalizeUiPreferences(auth.user.ui_preferences);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(""); const [testResult, setTestResult] = useState("");
@@ -118,12 +144,30 @@ export default function SettingsPage({
}); });
useEffect(() => { useEffect(() => {
if (requestedSection && !settingsSectionAvailable(settingsSubnav, requestedSection)) { if (requestedSection && !settingsSectionAvailable(availableSectionIds, requestedSection)) {
const next = new URLSearchParams(searchParams); const next = new URLSearchParams(searchParams);
next.set("section", "interface"); next.set("section", "interface");
setSearchParams(next, { replace: true }); setSearchParams(next, { replace: true });
} }
}, [requestedSection, searchParams, setSearchParams, settingsSubnav]); }, [availableSectionIds, requestedSection, searchParams, setSearchParams]);
useEffect(() => {
if (auth.profile_loaded) return;
let cancelled = false;
fetchAuthProfile(settings)
.then((next) => {if (!cancelled) onAuthChange(next);})
.catch(() => undefined);
return () => {cancelled = true;};
}, [auth.profile_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (auth.roles_loaded) return;
let cancelled = false;
fetchAuthRoles(settings)
.then((next) => {if (!cancelled) onAuthChange(next);})
.catch(() => undefined);
return () => {cancelled = true;};
}, [auth.roles_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => { useEffect(() => {
setProfileName(auth.user.display_name || ""); setProfileName(auth.user.display_name || "");
@@ -329,12 +373,16 @@ export default function SettingsPage({
</select> </select>
</FormField> </FormField>
<FormField label="i18n:govoplan-core.theme.a797e309"> <FormField label="i18n:govoplan-core.theme.a797e309">
<select value={theme} onChange={(event) => setTheme(event.target.value as UserUiTheme)}> <SegmentedControl
{UI_THEME_OPTIONS.map((item) => options={UI_THEME_OPTIONS.map((item) => ({ id: item.value, label: item.label }))}
<option key={item.value} value={item.value}>{item.label}</option> value={theme}
)} onChange={setTheme}
</select> role="group"
size="equal"
width="fill"
ariaLabel="i18n:govoplan-core.theme.a797e309" />
</FormField> </FormField>
<ThemePreview theme={theme} />
<dl className="detail-list compact-detail-list"> <dl className="detail-list compact-detail-list">
<div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div> <div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div>
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div> <div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div>
@@ -413,24 +461,15 @@ export default function SettingsPage({
</div> </div>
} }
{active === "notifications" && {activeContributedSection &&
<div className="dashboard-grid settings-dashboard-grid"> activeContributedSection.render({
<Card title="i18n:govoplan-core.notification_preferences.0ead6c12"> settings,
<p className="muted">i18n:govoplan-core.prepared_for_later_personal_notification_prefere.3fe73f86</p> auth,
<div className="placeholder-stack"> onAuthChange,
<span>i18n:govoplan-core.in_app_completion_notices.b68f2f4c</span> activeSection: active,
<span>i18n:govoplan-core.email_summary_preferences.b6c1dfb1</span> availableSections: availableSectionIds,
<span>i18n:govoplan-core.failure_and_warning_alerts.939d21c2</span> selectSection
</div> })
</Card>
<Card title="i18n:govoplan-core.quiet_ui_mode.1b0bd558">
<div className="placeholder-stack">
<span>i18n:govoplan-core.mute_non_critical_banners.27b23a4a</span>
<span>i18n:govoplan-core.batch_repetitive_notices.cc893559</span>
<span>i18n:govoplan-core.keep_validation_and_send_warnings_visible.9d6e0cf4</span>
</div>
</Card>
</div>
} }
</div> </div>
</section> </section>
@@ -439,8 +478,30 @@ export default function SettingsPage({
} }
function settingsSectionAvailable(groups: ModuleSubnavGroup<SettingsSection>[], section: SettingsSection | null | undefined): section is SettingsSection { function settingsSectionAvailable(sections: ReadonlySet<string>, section: string | null | undefined): section is SettingsSection {
return Boolean(section && groups.some((group) => group.items.some((item) => "id" in item && item.id === section))); return Boolean(section && sections.has(section));
}
function canUseSettingsContribution(auth: AuthInfo, section: SettingsSectionContribution): boolean {
if (section.allOf?.some((scope) => !hasScope(auth, scope))) {
return false;
}
if (section.anyOf && !hasAnyScope(auth, section.anyOf)) {
return false;
}
return true;
}
function sectionOrder(sectionId: string, contributedSections: SettingsSectionContribution[]): number {
const builtInOrder: Record<string, number> = {
profile: 10,
"mail-profiles": 20,
"file-connectors": 30,
interface: 10,
workspace: 20,
"local-connection": 30
};
return builtInOrder[sectionId] ?? contributedSections.find((section) => section.id === sectionId)?.order ?? 100;
} }
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences { function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
@@ -457,3 +518,24 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
function themeLabel(value: UserUiTheme): string { function themeLabel(value: UserUiTheme): string {
return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label; return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label;
} }
function ThemePreview({ theme }: {theme: UserUiTheme;}) {
const variants: UserUiTheme[] = theme === "system" ? ["light", "dark"] : [theme];
return (
<div className="theme-preview-list" aria-label="i18n:govoplan-core.theme.a797e309">
{variants.map((variant) =>
<div key={variant} className="theme-preview" data-preview-theme={variant}>
<div className="theme-preview-header">
<span>{themeLabel(variant)}</span>
<i />
</div>
<div className="theme-preview-body">
<strong>GovOPlaN</strong>
<span />
<span />
</div>
</div>
)}
</div>);
}

View File

@@ -3,6 +3,26 @@ export * from "./types";
export * from "./api/client"; export * from "./api/client";
export * from "./api/auth"; export * from "./api/auth";
export * from "./api/platform"; export * from "./api/platform";
export * from "./api/adminCommon";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "./api/mailContracts";
export type {
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapFolderResponse,
MailImapTestPayload,
MailPolicySourceStep,
MailProfilePatternRules,
MailProfilePolicyLimitKey,
MailProfilePolicyLimitPermissions,
MailProfilePolicyResponse,
MailServerProfileCredentialsPayload,
MailServerProfileListResponse,
MailServerProfilePayload,
MailSmtpTestPayload,
MailTransportCredentialsPayload,
MockMailboxMessage,
MockMailboxMessageResponse
} from "./api/mailContracts";
export * from "./api/privacyRetention"; export * from "./api/privacyRetention";
export * from "./platform/modules"; export * from "./platform/modules";
export * from "./platform/ModuleContext"; export * from "./platform/ModuleContext";
@@ -32,8 +52,12 @@ export { default as ConfirmDialog } from "./components/ConfirmDialog";
export { default as ConnectionTree } from "./components/ConnectionTree"; export { default as ConnectionTree } from "./components/ConnectionTree";
export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree"; export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree";
export { default as ColorPickerField } from "./components/ColorPickerField"; export { default as ColorPickerField } from "./components/ColorPickerField";
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel";
export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField"; export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField";
export { default as Dialog } from "./components/Dialog"; export { default as Dialog } from "./components/Dialog";
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
export { default as DismissibleAlert } from "./components/DismissibleAlert"; export { default as DismissibleAlert } from "./components/DismissibleAlert";
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock"; export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock"; export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
@@ -44,6 +68,8 @@ export { default as GuidedConfigDialog } from "./components/GuidedConfigDialog";
export type { GuidedConfigDialogProps } from "./components/GuidedConfigDialog"; export type { GuidedConfigDialogProps } from "./components/GuidedConfigDialog";
export { default as GuidedReviewList } from "./components/GuidedReviewList"; export { default as GuidedReviewList } from "./components/GuidedReviewList";
export type { GuidedReviewItem } from "./components/GuidedReviewList"; export type { GuidedReviewItem } from "./components/GuidedReviewList";
export { default as HoverTooltip } from "./components/HoverTooltip";
export type { HoverTooltipProps, HoverTooltipTone } from "./components/HoverTooltip";
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 { default as ExplorerTree } from "./components/ExplorerTree";
@@ -71,7 +97,7 @@ export type { UnsavedDraftGuardOptions } from "./components/UnsavedChangesGuard"
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard"; export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard";
export { default as EmailAddressInput } from "./components/email/EmailAddressInput"; export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel"; export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsPanelProps, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel"; export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, 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";

View File

@@ -1,5 +1,5 @@
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import type { ApiSettings, AuthInfo, PlatformNavItem } from "../types"; import type { ApiSettings, AuthInfo, AuthUpdate, PlatformNavItem } from "../types";
import IconRail from "./IconRail"; import IconRail from "./IconRail";
import Titlebar from "./Titlebar"; import Titlebar from "./Titlebar";
import BreadcrumbBar from "./BreadcrumbBar"; import BreadcrumbBar from "./BreadcrumbBar";
@@ -9,7 +9,7 @@ type Props = {
settings: ApiSettings; settings: ApiSettings;
auth: AuthInfo | null; auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void; onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void; onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
publicMode?: boolean; publicMode?: boolean;
navItems?: PlatformNavItem[]; navItems?: PlatformNavItem[];
maintenanceMode?: { enabled: boolean; message?: string | null }; maintenanceMode?: { enabled: boolean; message?: string | null };

View File

@@ -10,6 +10,8 @@ import type { PlatformWebModule } from "../types";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext"; import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de";
export default function HelpMenu() { export default function HelpMenu() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false);
@@ -50,8 +52,11 @@ export default function HelpMenu() {
} }
function openDocs(type: "user" | "admin") { function openDocs(type: "user" | "admin") {
if (!docsAvailable) return;
setOpen(false); setOpen(false);
if (!docsAvailable) {
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
return;
}
const params = new URLSearchParams({ type }); const params = new URLSearchParams({ type });
params.set("context", helpContext.id); params.set("context", helpContext.id);
navigate(`/docs?${params.toString()}`); navigate(`/docs?${params.toString()}`);
@@ -79,10 +84,10 @@ export default function HelpMenu() {
<HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small> <HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small>
</button> </button>
<hr /> <hr />
<button className="dropdown-item" onClick={() => openDocs("user")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}> <button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")} <BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
</button> </button>
<button className="dropdown-item" onClick={() => openDocs("admin")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}> <button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")} <BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
</button> </button>
<hr /> <hr />
@@ -96,6 +101,11 @@ export default function HelpMenu() {
} }
function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
const params = new URLSearchParams({ type, context: context.id });
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
}
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) { function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
return ( return (

View File

@@ -1,4 +1,4 @@
import { Settings } from "lucide-react"; import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router-dom"; import { NavLink, useLocation } from "react-router-dom";
import { useEffect, useMemo, useState, type MouseEvent } from "react"; import { useEffect, useMemo, useState, type MouseEvent } from "react";
import type { AuthInfo, PlatformNavItem } from "../types"; import type { AuthInfo, PlatformNavItem } from "../types";
@@ -7,6 +7,7 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard"; import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav"; const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] { function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
return [...navItems]. return [...navItems].
@@ -22,14 +23,11 @@ export default function IconRail({
compact = false, compact = false,
auth = null, auth = null,
navItems = [] navItems = []
}: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) { }: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) {
const location = useLocation(); const location = useLocation();
const items = visibleNavItems(auth, navItems); const items = visibleNavItems(auth, navItems);
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets()); const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
const [expanded, setExpanded] = useState(() => loadRailExpanded());
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]); const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
@@ -46,15 +44,27 @@ export default function IconRail({
}); });
}, [location.hash, location.pathname, location.search, topLevelItems]); }, [location.hash, location.pathname, location.search, topLevelItems]);
function toggleExpanded() {
setExpanded((current) => {
const next = !current;
saveRailExpanded(next);
return next;
});
}
function handleNavClick(event: MouseEvent<HTMLAnchorElement>, target: string) { function handleNavClick(event: MouseEvent<HTMLAnchorElement>, target: string) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return; if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault(); event.preventDefault();
navigate(target); navigate(target);
} }
const railExpanded = !compact && expanded;
return ( return (
<aside className={`icon-rail ${compact ? "compact" : ""}`}> <aside className={`icon-rail ${compact ? "compact" : ""} ${railExpanded ? "expanded" : ""}`}>
<div className="icon-rail-header">
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div> <div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
</div>
{!compact && {!compact &&
<> <>
@@ -62,9 +72,11 @@ export default function IconRail({
{items.map(({ to, label, icon: Icon }) => { {items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to; const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to); const active = modulePathActive(location.pathname, to);
const renderedLabel = translateText(label);
return ( return (
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={translateText(label)} onClick={(event) => handleNavClick(event, target)}> <NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={renderedLabel} onClick={(event) => handleNavClick(event, target)}>
{Icon ? <Icon size={20} /> : label.slice(0, 1)} {Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
<span className="icon-nav-label">{renderedLabel}</span>
</NavLink>); </NavLink>);
})} })}
@@ -72,7 +84,17 @@ export default function IconRail({
<div className="icon-rail-bottom"> <div className="icon-rail-bottom">
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}> <NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
<Settings size={20} /> <Settings size={20} />
<span className="icon-nav-label">{translateText("i18n:govoplan-core.settings.c7f73bb5")}</span>
</NavLink> </NavLink>
<button
type="button"
className="icon-nav-item icon-rail-toggle"
aria-label={railExpanded ? "Collapse navigation" : "Expand navigation"}
title={railExpanded ? "Collapse navigation" : "Expand navigation"}
onClick={toggleExpanded}>
{railExpanded ? <PanelLeftClose size={20} aria-hidden="true" /> : <PanelLeftOpen size={20} aria-hidden="true" />}
<span className="icon-nav-label">{railExpanded ? "Collapse" : "Expand"}</span>
</button>
</div> </div>
</> </>
} }
@@ -107,7 +129,23 @@ function saveRememberedTargets(targets: Record<string, string>): void {
try { try {
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets)); window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
} catch { } catch {
// Remembered navigation is a convenience only. // Remembered navigation is a convenience only.
}} }}
function loadRailExpanded(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(RAIL_EXPANDED_STORAGE_KEY) === "true";
} catch {
return false;
}
}
function saveRailExpanded(expanded: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(RAIL_EXPANDED_STORAGE_KEY, expanded ? "true" : "false");
} catch {
// Rail width is a presentation preference only.
}
}

View File

@@ -1,20 +1,27 @@
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect } from "react";
import { Check, LogOut, Settings, UserCircle } from "lucide-react"; import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from "../types"; import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse } from "../types";
import HelpMenu from "./HelpMenu"; import HelpMenu from "./HelpMenu";
import LanguageMenu from "./LanguageMenu"; import LanguageMenu from "./LanguageMenu";
import LoginModal from "../features/auth/LoginModal"; import LoginModal from "../features/auth/LoginModal";
import DismissibleAlert from "../components/DismissibleAlert"; import DismissibleAlert from "../components/DismissibleAlert";
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard"; import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { apiFetch, isApiError } from "../api/client";
import { logout, switchTenant } from "../api/auth"; import { logout, switchTenant } from "../api/auth";
import { hasAnyScope } from "../utils/permissions"; import { hasAnyScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import { usePlatformModules } from "../platform/ModuleContext";
type NotificationSummary = {
unread: number;
show_unread_badge?: boolean;
};
type Props = { type Props = {
settings: ApiSettings; settings: ApiSettings;
auth: AuthInfo | null; auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void; onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void; onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
maintenanceMode?: {enabled: boolean;message?: string | null;}; maintenanceMode?: {enabled: boolean;message?: string | null;};
}; };
@@ -26,9 +33,11 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
const [loginOpen, setLoginOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false);
const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null); const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null);
const [tenantError, setTenantError] = useState(""); const [tenantError, setTenantError] = useState("");
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const accountRef = useRef<HTMLDivElement>(null); const accountRef = useRef<HTMLDivElement>(null);
const tenantRef = useRef<HTMLDivElement>(null); const tenantRef = useRef<HTMLDivElement>(null);
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const modules = usePlatformModules();
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null; const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []); const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
@@ -41,6 +50,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
"system:tenants:suspend"] "system:tenants:suspend"]
); );
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants)); const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount);
useEffect(() => { useEffect(() => {
function onPointerDown(event: MouseEvent) { function onPointerDown(event: MouseEvent) {
@@ -56,6 +67,41 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
return () => window.removeEventListener("mousedown", onPointerDown); return () => window.removeEventListener("mousedown", onPointerDown);
}, []); }, []);
useEffect(() => {
if (!auth || !notificationsAvailable) {
setUnreadNotificationCount(0);
return;
}
let cancelled = false;
async function refreshNotificationSummary() {
try {
const summary = await apiFetch<NotificationSummary>(settings, "/api/v1/notifications/summary", { cache: "no-store" });
if (!cancelled) {
setUnreadNotificationCount(summary.show_unread_badge === false ? 0 : Math.max(0, Number(summary.unread) || 0));
}
} catch (error) {
if (!cancelled) {
setUnreadNotificationCount(0);
}
if (!isApiError(error, 401, 403, 404)) {
console.error("Failed to load notification summary", error);
}
}
}
void refreshNotificationSummary();
const intervalId = window.setInterval(refreshNotificationSummary, 60_000);
window.addEventListener("focus", refreshNotificationSummary);
window.addEventListener("govoplan:notifications-changed", refreshNotificationSummary);
return () => {
cancelled = true;
window.clearInterval(intervalId);
window.removeEventListener("focus", refreshNotificationSummary);
window.removeEventListener("govoplan:notifications-changed", refreshNotificationSummary);
};
}, [activeTenant?.id, auth?.user?.id, notificationsAvailable, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
function handleLogin(response: LoginResponse) { function handleLogin(response: LoginResponse) {
onAuthChange(response, ""); onAuthChange(response, "");
} }
@@ -109,6 +155,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
navigate("/admin?section=system-settings"); navigate("/admin?section=system-settings");
} }
function openNotificationCenter() {
navigate("/notifications");
}
return ( return (
<header className="titlebar"> <header className="titlebar">
{maintenanceMode?.enabled && {maintenanceMode?.enabled &&
@@ -159,8 +209,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<div className="titlebar-spacer" /> <div className="titlebar-spacer" />
<HelpMenu />
<LanguageMenu /> <LanguageMenu />
<HelpMenu />
{auth && notificationsAvailable &&
<button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
<Bell size={18} />
{unreadNotificationCount > 0 &&
<span className="titlebar-notification-badge" aria-label={`${unreadNotificationCount} unread`}>
{notificationBadgeLabel}
</span>
}
</button>
}
<div className="context-menu-wrap" ref={accountRef}> <div className="context-menu-wrap" ref={accountRef}>
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}> <button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>

View File

@@ -1,4 +1,4 @@
import { Activity, Building2, CalendarDays, ClipboardPenLine, FileText, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react"; import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
import installedWebModules from "virtual:govoplan-installed-modules"; import installedWebModules from "virtual:govoplan-installed-modules";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types"; import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
import { import {
@@ -39,9 +39,12 @@ type RemoteAssetManifest = {
const iconByName: Record<string, LucideIcon> = { const iconByName: Record<string, LucideIcon> = {
activity: Activity, activity: Activity,
admin: Shield, admin: Shield,
bell: Bell,
"book-user": BookUser,
building: Building2, building: Building2,
"building-2": Building2, "building-2": Building2,
calendar: CalendarDays, calendar: CalendarDays,
"calendar-clock": CalendarClock,
campaign: Mails, campaign: Mails,
"clipboard-pen-line": ClipboardPenLine, "clipboard-pen-line": ClipboardPenLine,
dashboard: LayoutDashboard, dashboard: LayoutDashboard,
@@ -51,9 +54,12 @@ const iconByName: Record<string, LucideIcon> = {
form: Form, form: Form,
"layout-template": LayoutTemplate, "layout-template": LayoutTemplate,
mail: Mail, mail: Mail,
notifications: Bell,
organizations: Building2,
operator: RadioTower, operator: RadioTower,
"radio-tower": RadioTower, "radio-tower": RadioTower,
reports: FileText, reports: ClipboardPenLine,
templates: LayoutTemplate,
users: Users users: Users
}; };

View File

@@ -17,8 +17,8 @@
overflow: auto; overflow: auto;
height: 100%; height: 100%;
background: background:
radial-gradient(circle at 18% 18%, rgba(239, 107, 58, .13), transparent 24rem), radial-gradient(circle at 18% 18%, var(--accent-auth-glow), transparent 24rem),
radial-gradient(circle at 78% 14%, rgba(126, 166, 197, .15), transparent 22rem), radial-gradient(circle at 78% 14%, var(--blue-auth-glow), transparent 22rem),
var(--bg); var(--bg);
} }
@@ -32,7 +32,7 @@
.public-card { .public-card {
width: min(720px, 100%); width: min(720px, 100%);
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--shadow); box-shadow: var(--shadow);
padding: 42px 48px; padding: 42px 48px;
@@ -134,9 +134,9 @@
.titlebar-link:hover, .titlebar-link:hover,
.account-pill:hover, .account-pill:hover,
.context-menu-wrap:focus-within .account-pill { .context-menu-wrap:focus-within .account-pill {
background: rgba(0,0,0,.055); background: var(--titlebar-hover-bg);
color: var(--text-strong); color: var(--text-strong);
box-shadow: inset 0 0 0 1px rgba(0,0,0,.04); box-shadow: inset 0 0 0 1px rgba(var(--shadow-color-rgb), .04);
} }
.dropdown-menu { .dropdown-menu {
@@ -151,7 +151,7 @@
.dropdown-item:hover, .dropdown-item:hover,
.dropdown-item.active { .dropdown-item.active {
background: rgba(239, 107, 58, .10); background: var(--accent-hover-bg);
color: var(--text-strong); color: var(--text-strong);
} }

View File

@@ -1,6 +1,6 @@
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: #e7e4df; color: #666; text-transform: uppercase; } .status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: #d6eee9; color: #34796d; } .status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text-strong); }
.status-warning, .status-needs-review, .status-pending { background: #ffedc6; color: #a06b00; } .status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); }
.status-blocked, .status-failed, .status-failed-permanent { background: #f8d1cc; color: #b13e35; } .status-blocked, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
.status-queued, .status-sending { background: #d8e8f4; color: #386a90; } .status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); }
.status-inactive, .status-locked { background: #e7e4df; color: #666; } .status-inactive, .status-locked { background: var(--status-neutral-bg); color: var(--text-soft); }

View File

@@ -19,9 +19,9 @@
display: inline-block; display: inline-block;
width: 18px; width: 18px;
height: 13px; height: 13px;
border: 2px solid #8c8881; border: 2px solid var(--loading-line);
border-radius: 3px; border-radius: 3px;
background: rgba(255,255,255,.7); background: var(--loading-envelope-bg);
animation: loading-envelope-float .74s ease-in-out infinite alternate; animation: loading-envelope-float .74s ease-in-out infinite alternate;
} }
.loading-envelope::before, .loading-envelope::before,
@@ -31,7 +31,7 @@
top: 1px; top: 1px;
width: 10px; width: 10px;
height: 10px; height: 10px;
border-top: 2px solid #8c8881; border-top: 2px solid var(--loading-line);
} }
.loading-envelope::before { .loading-envelope::before {
left: 1px; left: 1px;
@@ -64,19 +64,19 @@
align-items: center; align-items: center;
gap: 7px; gap: 7px;
max-width: 100%; max-width: 100%;
border: 1px solid #c9c5bd; border: 1px solid var(--control-border);
border-radius: 999px; border-radius: 999px;
background: linear-gradient(#ffffff, #f2f1ef); background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end));
box-shadow: inset 0 1px 0 rgba(255,255,255,.85), 0 1px 1px rgba(0,0,0,.05); box-shadow: var(--shadow-control-strong);
padding: 5px 8px 5px 11px; padding: 5px 8px 5px 11px;
color: var(--text-strong); color: var(--text-strong);
font-size: 13px; font-size: 13px;
line-height: 1.2; line-height: 1.2;
} }
.email-chip.invalid { .email-chip.invalid {
border-color: #c96b63; border-color: var(--danger-border);
background: #f6e3df; background: var(--danger-soft);
color: #873c35; color: var(--danger-text-deep);
} }
.email-chip-main { .email-chip-main {
overflow: hidden; overflow: hidden;
@@ -99,14 +99,14 @@
height: 18px; height: 18px;
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
background: rgba(0,0,0,.08); background: var(--hover-tint);
color: #57534d; color: var(--control-text-muted);
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
line-height: 1; line-height: 1;
padding: 0; padding: 0;
} }
.email-chip-remove:hover { background: rgba(0,0,0,.15); } .email-chip-remove:hover { background: var(--hover-tint-strong); }
.email-chip-empty { .email-chip-empty {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
@@ -148,7 +148,7 @@
align-items: center; align-items: center;
gap: 8px 10px; gap: 8px 10px;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
color: var(--muted); color: var(--muted);
@@ -224,14 +224,40 @@
transform: translateY(-50%); transform: translateY(-50%);
} }
.password-field-toggle:hover { .password-field-toggle:hover {
background: rgba(0, 0, 0, .06); background: var(--hover-tint-soft);
color: var(--text-strong); color: var(--text-strong);
} }
.password-field-toggle:focus-visible { .password-field-toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--blue) 55%, #fff); outline: var(--focus-outline);
outline-offset: 1px; outline-offset: 1px;
} }
.credential-panel {
display: grid;
gap: 12px;
min-width: 0;
}
.credential-panel-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
align-items: start;
}
.credential-panel-heading {
grid-column: 1 / -1;
margin: 2px 0 -4px;
color: var(--text-strong);
font-size: 13px;
font-weight: 800;
}
.credential-panel-extra {
display: grid;
gap: 10px;
}
.color-picker-field, .color-picker-field,
.date-field, .date-field,
.time-field { .time-field {
@@ -261,9 +287,9 @@
z-index: 1; z-index: 1;
width: 18px; width: 18px;
height: 18px; height: 18px;
border: 1px solid var(--line-dark); border: var(--border-line-dark);
border-radius: 4px; border-radius: 4px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.45); box-shadow: var(--shadow-inset-highlight);
} }
.color-picker-trigger, .color-picker-trigger,
@@ -287,7 +313,7 @@
.color-picker-trigger:focus-visible, .color-picker-trigger:focus-visible,
.date-field-trigger:hover:not(:disabled), .date-field-trigger:hover:not(:disabled),
.date-field-trigger:focus-visible { .date-field-trigger:focus-visible {
background: rgba(0,0,0,.08); background: var(--hover-tint);
color: var(--text-strong); color: var(--text-strong);
} }
@@ -307,10 +333,10 @@
width: max-content; width: max-content;
min-width: 220px; min-width: 220px;
padding: 10px; padding: 10px;
border: 1px solid var(--line-dark); border: var(--border-line-dark);
border-radius: 8px; border-radius: 8px;
background: var(--panel); background: var(--panel);
box-shadow: 0 16px 34px rgba(0,0,0,.18); box-shadow: var(--shadow-menu);
} }
.color-picker-grid { .color-picker-grid {
@@ -322,10 +348,10 @@
.color-picker-grid button { .color-picker-grid button {
width: 28px; width: 28px;
height: 28px; height: 28px;
border: 1px solid rgba(0,0,0,.2); border: 1px solid var(--black-border-soft);
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.45); box-shadow: var(--shadow-inset-highlight);
} }
.color-picker-grid button.is-selected { .color-picker-grid button.is-selected {
@@ -355,7 +381,7 @@
justify-content: center; justify-content: center;
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 4px; border-radius: 4px;
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
@@ -401,9 +427,9 @@
} }
.date-field-grid button.is-selected { .date-field-grid button.is-selected {
border-color: #5aa99b; border-color: var(--success-border);
background: var(--green); background: var(--green);
color: #fff; color: var(--on-accent);
font-weight: 800; font-weight: 800;
} }
@@ -426,7 +452,7 @@
} }
.admin-overview-link { .admin-overview-link {
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel-soft); background: var(--panel-soft);
padding: 14px; padding: 14px;
@@ -436,7 +462,7 @@
} }
.admin-overview-link:hover { .admin-overview-link:hover {
background: #fff; background: var(--surface);
border-color: var(--line-dark); border-color: var(--line-dark);
} }
@@ -467,7 +493,7 @@
grid-template-columns: minmax(0, 1fr) 170px; grid-template-columns: minmax(0, 1fr) 170px;
align-items: center; align-items: center;
gap: 18px; gap: 18px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel); background: var(--panel);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -551,7 +577,7 @@
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel); background: var(--panel);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -616,7 +642,7 @@
.module-install-preflight { .module-install-preflight {
display: grid; display: grid;
gap: 10px; gap: 10px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel-soft); background: var(--panel-soft);
padding: 12px 14px; padding: 12px 14px;
@@ -657,7 +683,7 @@
min-height: 30px; min-height: 30px;
padding: 6px 8px; padding: 6px 8px;
border-radius: 4px; border-radius: 4px;
background: rgba(255,255,255,.58); background: var(--panel-glass);
} }
.module-install-preflight-issue strong { .module-install-preflight-issue strong {
@@ -686,9 +712,9 @@
gap: 4px; gap: 4px;
align-content: start; align-content: start;
min-height: 86px; min-height: 86px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: rgba(255,255,255,.6); background: var(--panel-glass);
padding: 9px 10px; padding: 9px 10px;
} }
@@ -743,7 +769,7 @@
grid-template-columns: 130px 150px 170px minmax(260px, 1fr); grid-template-columns: 130px 150px 170px minmax(260px, 1fr);
gap: 12px; gap: 12px;
align-items: end; align-items: end;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel); background: var(--panel);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -818,7 +844,7 @@
grid-template-columns: minmax(0, 1fr) minmax(220px, 34%); grid-template-columns: minmax(0, 1fr) minmax(220px, 34%);
align-items: center; align-items: center;
gap: 14px; gap: 14px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel); background: var(--panel);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -976,9 +1002,9 @@
max-height: 300px; max-height: 300px;
overflow: auto; overflow: auto;
padding: 8px; padding: 8px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-muted, #f6f7f9); background: var(--surface-muted);
} }
.admin-selection-item { .admin-selection-item {
@@ -992,7 +1018,7 @@
} }
.admin-selection-item:hover { .admin-selection-item:hover {
background: var(--surface, #fff); background: var(--surface);
} }
.admin-selection-item.disabled { .admin-selection-item.disabled {
@@ -1036,8 +1062,8 @@
.guided-config-sidebar { .guided-config-sidebar {
min-width: 0; min-width: 0;
border-right: 1px solid var(--line); border-right: var(--border-line);
background: #f6f5f3; background: var(--panel-soft);
} }
.guided-config-sidebar .stepper { .guided-config-sidebar .stepper {
@@ -1109,7 +1135,7 @@
gap: 14px; gap: 14px;
min-width: 0; min-width: 0;
padding: 15px; padding: 15px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 7px; border-radius: 7px;
background: var(--panel); background: var(--panel);
} }
@@ -1134,7 +1160,7 @@
} }
.advanced-options-panel { .advanced-options-panel {
border: 1px solid var(--line); border: var(--border-line);
border-radius: 7px; border-radius: 7px;
background: var(--panel); background: var(--panel);
} }
@@ -1248,7 +1274,7 @@
} }
.action-blocker-technical { .action-blocker-technical {
background: rgba(255,255,255,.56); background: var(--panel-glass);
} }
.guided-review-list { .guided-review-list {
@@ -1271,7 +1297,7 @@
align-items: start; align-items: start;
min-width: 0; min-width: 0;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--line); border: var(--border-line);
border-left-width: 4px; border-left-width: 4px;
border-left-color: var(--line-dark); border-left-color: var(--line-dark);
border-radius: 7px; border-radius: 7px;
@@ -1327,7 +1353,7 @@
.guided-config-sidebar { .guided-config-sidebar {
border-right: 0; border-right: 0;
border-bottom: 1px solid var(--line); border-bottom: var(--border-line);
} }
.guided-config-sidebar .stepper { .guided-config-sidebar .stepper {
@@ -1350,19 +1376,19 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 7px; gap: 7px;
min-height: 44px; min-height: 44px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 8px; border-radius: 8px;
background: #fff; background: var(--surface);
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 2px rgba(0,0,0,.035); box-shadow: var(--shadow-control-surface);
padding: 7px 8px; padding: 7px 8px;
} }
.email-address-editor:focus-within { .email-address-editor:focus-within {
border-color: #9bb7d3; border-color: var(--input-border-focus);
box-shadow: 0 0 0 3px rgba(82, 130, 177, .14), inset 0 1px 0 rgba(255,255,255,.8); box-shadow: var(--focus-ring), var(--shadow-inset-highlight-strong);
} }
.email-address-editor.has-error { .email-address-editor.has-error {
border-color: #c96b63; border-color: var(--danger-border);
box-shadow: 0 0 0 3px rgba(201, 107, 99, .13); box-shadow: var(--danger-focus-ring-soft);
} }
.email-address-editor .email-chip-list { .email-address-editor .email-chip-list {
display: flex; display: flex;
@@ -1402,10 +1428,10 @@
justify-content: center; justify-content: center;
width: 28px; width: 28px;
height: 28px; height: 28px;
border: 1px solid #c7c2b8; border: 1px solid var(--control-border);
border-radius: 999px; border-radius: 999px;
background: linear-gradient(#fff, #f0efec); background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
color: #4f4b46; color: var(--control-text);
cursor: pointer; cursor: pointer;
font-size: 19px; font-size: 19px;
font-weight: 800; font-weight: 800;
@@ -1413,8 +1439,8 @@
padding: 0; padding: 0;
} }
.email-address-plus:hover { .email-address-plus:hover {
border-color: #aaa49a; border-color: var(--control-border-hover);
background: linear-gradient(#fff, #e9e7e3); background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-hover));
} }
.email-address-popover { .email-address-popover {
position: absolute; position: absolute;
@@ -1424,9 +1450,9 @@
width: min(360px, calc(100vw - 64px)); width: min(360px, calc(100vw - 64px));
display: grid; display: grid;
gap: 10px; gap: 10px;
border: 1px solid var(--line-dark); border: var(--border-line-dark);
border-radius: 8px; border-radius: 8px;
background: #fff; background: var(--surface);
box-shadow: var(--shadow-popover); box-shadow: var(--shadow-popover);
padding: 14px; padding: 14px;
} }
@@ -1449,10 +1475,10 @@
display: grid; display: grid;
gap: 4px; gap: 4px;
max-width: 560px; max-width: 560px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 8px; border-radius: 8px;
background: #fff; background: var(--surface);
box-shadow: 0 8px 24px rgba(0,0,0,.08); box-shadow: 0 8px 24px var(--hover-tint);
padding: 6px; padding: 6px;
} }
.email-address-suggestions button { .email-address-suggestions button {
@@ -1521,10 +1547,10 @@
flex: 0 0 auto; flex: 0 0 auto;
width: 42px; width: 42px;
height: 24px; height: 24px;
border: 1px solid #aeb4bb; border: 1px solid var(--toggle-track-border);
border-radius: 999px; border-radius: 999px;
background: #cfd4da; background: var(--toggle-track-bg);
box-shadow: inset 0 1px 2px rgba(0,0,0,.12); box-shadow: var(--shadow-control-inset-strong);
transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease; transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease;
} }
.toggle-switch-thumb { .toggle-switch-thumb {
@@ -1534,19 +1560,19 @@
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 999px; border-radius: 999px;
background: #fff; background: var(--surface);
box-shadow: 0 1px 2px rgba(0,0,0,.22); box-shadow: var(--shadow-thumb);
transition: transform .16s ease; transition: transform .16s ease;
} }
.toggle-switch-input:checked + .toggle-switch-track { .toggle-switch-input:checked + .toggle-switch-track {
border-color: #0d6efd; border-color: var(--primary);
background: #0d6efd; background: var(--primary);
} }
.toggle-switch-input:checked + .toggle-switch-track .toggle-switch-thumb { .toggle-switch-input:checked + .toggle-switch-track .toggle-switch-thumb {
transform: translateX(18px); transform: translateX(18px);
} }
.toggle-switch-input:focus-visible + .toggle-switch-track { .toggle-switch-input:focus-visible + .toggle-switch-track {
box-shadow: 0 0 0 3px rgba(13,110,253,.2), inset 0 1px 2px rgba(0,0,0,.12); box-shadow: var(--primary-focus-ring-soft), var(--shadow-control-inset-strong);
} }
.toggle-switch-input:disabled + .toggle-switch-track { .toggle-switch-input:disabled + .toggle-switch-track {
filter: grayscale(.15); filter: grayscale(.15);
@@ -1604,7 +1630,7 @@
width: 16px; width: 16px;
height: 16px; height: 16px;
border-radius: 999px; border-radius: 999px;
color: #686560; color: var(--text-subtle);
background: var(--line-dark); background: var(--line-dark);
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
@@ -1627,7 +1653,7 @@
.policy-table { .policy-table {
display: grid; display: grid;
gap: 0; gap: 0;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
overflow: hidden; overflow: hidden;
} }
@@ -1646,7 +1672,7 @@
gap: 14px; gap: 14px;
padding: 12px 14px; padding: 12px 14px;
background: var(--surface); background: var(--surface);
border-top: 1px solid var(--line); border-top: var(--border-line);
} }
.policy-row:first-child { .policy-row:first-child {
border-top: 0; border-top: 0;
@@ -1712,7 +1738,7 @@
width: max-content; width: max-content;
max-width: min(320px, calc(100vw - 48px)); max-width: min(320px, calc(100vw - 48px));
transform: translate(-50%, 3px); transform: translate(-50%, 3px);
border: 1px solid var(--line-dark); border: var(--border-line-dark);
border-radius: 7px; border-radius: 7px;
background: var(--surface); background: var(--surface);
box-shadow: var(--shadow-popover); box-shadow: var(--shadow-popover);
@@ -1734,8 +1760,8 @@
top: 100%; top: 100%;
width: 9px; width: 9px;
height: 9px; height: 9px;
border-right: 1px solid var(--line-dark); border-right: var(--border-line-dark);
border-bottom: 1px solid var(--line-dark); border-bottom: var(--border-line-dark);
background: var(--surface); background: var(--surface);
transform: translate(-50%, -5px) rotate(45deg); transform: translate(-50%, -5px) rotate(45deg);
} }
@@ -1767,8 +1793,8 @@
} }
.field-input-missing { .field-input-missing {
border-color: #b42318 !important; border-color: var(--danger-border-deep) !important;
box-shadow: 0 0 0 3px rgba(180, 35, 24, .14) !important; box-shadow: var(--danger-focus-ring) !important;
} }
.disabled-action-tooltip { .disabled-action-tooltip {
@@ -1776,19 +1802,27 @@
display: inline-flex; display: inline-flex;
} }
.disabled-action-tooltip:focus-visible {
outline: none;
}
.disabled-action-tooltip:focus-visible > .btn {
box-shadow: var(--focus-ring);
}
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):hover::after, .disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):hover::after,
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):focus-within::after { .disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):focus-within::after {
position: absolute; position: absolute;
right: 0; right: 0;
bottom: calc(100% + 10px); bottom: calc(100% + 10px);
z-index: 80; z-index: 20000;
width: min(280px, 72vw); width: min(280px, 72vw);
padding: 9px 11px; padding: 9px 11px;
border: 1px solid rgba(180, 35, 24, .24); border: 1px solid var(--border-danger-soft);
border-radius: var(--radius-sm, 8px); border-radius: var(--radius-sm, 8px);
color: #7a241c; color: var(--danger-text-tooltip);
background: #fff7f5; background: var(--danger-muted-bg);
box-shadow: var(--shadow-popover, 0 16px 32px rgba(15, 23, 42, .18)); box-shadow: var(--shadow-popover);
content: attr(data-tooltip); content: attr(data-tooltip);
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -1819,7 +1853,7 @@
min-height: 120px; min-height: 120px;
padding: 1.25rem; padding: 1.25rem;
border-radius: var(--radius-lg, 18px); border-radius: var(--radius-lg, 18px);
background: rgba(255, 255, 255, 0.00); background: var(--transparent-surface);
backdrop-filter: blur(1.5px); backdrop-filter: blur(1.5px);
margin: -10px; margin: -10px;
} }
@@ -1829,11 +1863,11 @@
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.65rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border: 1px solid var(--border-soft, rgba(15, 23, 42, 0.12)); border: 1px solid var(--border-soft);
border-radius: 999px; border-radius: 999px;
color: var(--text, #172033); color: var(--text);
background: rgba(255, 255, 255, 0.86); background: var(--panel-glass-strong);
box-shadow: var(--shadow-soft, 0 10px 28px rgba(15, 23, 42, 0.12)); box-shadow: var(--shadow-soft);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
} }
@@ -1853,21 +1887,21 @@
justify-content: center; justify-content: center;
width: 30px; width: 30px;
height: 30px; height: 30px;
border: 1px solid #c9c3b9; border: 1px solid var(--control-border);
border-radius: 999px; border-radius: 999px;
background: linear-gradient(#ffffff, #f1efeb); background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
color: #4f4a43; color: var(--control-text);
cursor: pointer; cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05); box-shadow: var(--shadow-control);
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease; transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
} }
.card-collapse-toggle:hover { .card-collapse-toggle:hover {
border-color: #aaa299; border-color: var(--control-border-pressed);
background: linear-gradient(#ffffff, #e8e5df); background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-pressed));
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08); box-shadow: var(--shadow-control-hover);
} }
.card-collapse-toggle:focus-visible { .card-collapse-toggle:focus-visible {
outline: 3px solid rgba(82, 130, 177, .22); outline: 3px solid var(--focus-ring-color);
outline-offset: 2px; outline-offset: 2px;
} }
.card-collapse-toggle svg { .card-collapse-toggle svg {
@@ -1907,7 +1941,7 @@
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
position: relative; position: relative;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08); box-shadow: var(--shadow-dismissible);
} }
.alert-dismissible .alert-message { .alert-dismissible .alert-message {
@@ -1932,9 +1966,9 @@
top: auto; top: auto;
width: 100%; width: 100%;
margin: 0; margin: 0;
border: 1px solid rgba(15, 23, 42, 0.14); border: 1px solid var(--border-soft);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.2); box-shadow: var(--shadow-floating);
pointer-events: auto; pointer-events: auto;
} }
@@ -1953,7 +1987,7 @@
.alert-dismiss:hover, .alert-dismiss:hover,
.alert-dismiss:focus-visible { .alert-dismiss:focus-visible {
opacity: 1; opacity: 1;
background: rgba(255, 255, 255, 0.45); background: var(--panel-glass-muted);
} }
@@ -2015,7 +2049,7 @@
.explorer-tree-node-wrap:hover, .explorer-tree-node-wrap:hover,
.explorer-tree-node-wrap:focus-within, .explorer-tree-node-wrap:focus-within,
.explorer-tree-node-wrap.is-active { .explorer-tree-node-wrap.is-active {
background: rgba(13, 110, 253, .08); background: var(--primary-soft);
outline: none; outline: none;
} }
@@ -2029,14 +2063,14 @@
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
border-right: 1px solid var(--line); border-right: var(--border-line);
background: linear-gradient(180deg, var(--panel-soft), var(--panel)); background: linear-gradient(180deg, var(--panel-soft), var(--panel));
} }
.file-tree-heading { .file-tree-heading {
flex: 0 0 auto; flex: 0 0 auto;
padding: 13px 14px; padding: 13px 14px;
border-bottom: 1px solid var(--line); border-bottom: var(--border-line);
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
@@ -2054,7 +2088,7 @@
.file-tree-space + .file-tree-space { .file-tree-space + .file-tree-space {
margin-top: 10px; margin-top: 10px;
padding-top: 10px; padding-top: 10px;
border-top: 1px solid var(--line); border-top: var(--border-line);
} }
.file-tree-node-wrap { .file-tree-node-wrap {
@@ -2143,8 +2177,8 @@
} }
.file-tree-node-wrap.is-selected .file-tree-select { .file-tree-node-wrap.is-selected .file-tree-select {
background: #0d6efd; background: var(--primary);
color: #fff; color: var(--on-accent);
} }
.file-tree-children { .file-tree-children {
@@ -2159,10 +2193,10 @@
justify-content: start; justify-content: start;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
border: 1px solid #c9c3b9; border: var(--border-line-dark);
border-radius: 8px; border-radius: 8px;
background: #c9c3b9; background: var(--line-dark);
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05); box-shadow: var(--shadow-control);
} }
.segmented-control-size-content { .segmented-control-size-content {
@@ -2186,18 +2220,18 @@
min-height: 32px; min-height: 32px;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
background: linear-gradient(#ffffff, #f1efeb); background: linear-gradient(var(--surface), var(--panel));
color: #4f4a43; color: var(--text);
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05); box-shadow: var(--shadow-control);
transition: background .18s ease, box-shadow .18s ease, color .18s ease; transition: background .18s ease, box-shadow .18s ease, color .18s ease;
} }
.segmented-control-option + .segmented-control-option { .segmented-control-option + .segmented-control-option {
border-left: 1px solid #c9c3b9; border-left: var(--border-line-dark);
} }
.segmented-control-option:first-child { .segmented-control-option:first-child {
@@ -2210,13 +2244,13 @@
.segmented-control-option:hover, .segmented-control-option:hover,
.segmented-control-option:focus-visible { .segmented-control-option:focus-visible {
background: linear-gradient(#ffffff, #e8e5df); background: linear-gradient(var(--surface), var(--bar));
color: #4f4a43; color: var(--text-strong);
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08); box-shadow: var(--shadow-control-hover);
} }
.segmented-control-option:focus-visible { .segmented-control-option:focus-visible {
outline: 3px solid rgba(82, 130, 177, .22); outline: 3px solid var(--focus-ring-color);
outline-offset: -1px; outline-offset: -1px;
} }
@@ -2224,7 +2258,7 @@
.segmented-control-option.active { .segmented-control-option.active {
background: var(--line); background: var(--line);
color: var(--text-strong); color: var(--text-strong);
box-shadow: inset 0 2px 5px rgba(0,0,0,.12), inset 0 -1px 0 rgba(255,255,255,.45); box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .12), var(--shadow-inset-highlight);
} }
.segmented-control-option.is-active:hover, .segmented-control-option.is-active:hover,
@@ -2232,7 +2266,7 @@
.segmented-control-option.active:hover, .segmented-control-option.active:hover,
.segmented-control-option.active:focus-visible { .segmented-control-option.active:focus-visible {
background: var(--line); background: var(--line);
box-shadow: inset 0 2px 5px rgba(0,0,0,.14), inset 0 -1px 0 rgba(255,255,255,.45); box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .14), var(--shadow-inset-highlight);
} }
.segmented-control-option:disabled { .segmented-control-option:disabled {
@@ -2240,6 +2274,84 @@
opacity: .55; opacity: .55;
} }
.theme-preview-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
}
.theme-preview {
--preview-bg: var(--theme-preview-light-bg);
--preview-bar: var(--theme-preview-light-bar);
--preview-surface: var(--theme-preview-light-surface);
--preview-line: var(--theme-preview-light-line);
--preview-text: var(--theme-preview-light-text);
--preview-muted: var(--theme-preview-light-muted);
display: grid;
gap: 0;
min-height: 112px;
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--preview-bg);
}
.theme-preview[data-preview-theme="dark"] {
--preview-bg: var(--theme-preview-dark-bg);
--preview-bar: var(--theme-preview-dark-bar);
--preview-surface: var(--theme-preview-dark-surface);
--preview-line: var(--theme-preview-dark-line);
--preview-text: var(--theme-preview-dark-text);
--preview-muted: var(--theme-preview-dark-muted);
}
.theme-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--preview-line);
background: var(--preview-bar);
color: var(--preview-text);
font-size: 12px;
font-weight: 800;
}
.theme-preview-header i {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.theme-preview-body {
display: grid;
align-content: start;
gap: 8px;
padding: 12px;
background: var(--preview-surface);
color: var(--preview-text);
}
.theme-preview-body strong {
font-size: 13px;
}
.theme-preview-body span {
display: block;
height: 8px;
border-radius: 4px;
background: var(--preview-line);
}
.theme-preview-body span:last-child {
width: 68%;
background: var(--preview-muted);
opacity: .45;
}
.message-display-panel { .message-display-panel {
display: grid; display: grid;
gap: 14px; gap: 14px;
@@ -2327,7 +2439,7 @@
max-height: 420px; max-height: 420px;
margin: 0; margin: 0;
overflow: auto; overflow: auto;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-subtle); background: var(--surface-subtle);
color: var(--text); color: var(--text);
@@ -2342,7 +2454,7 @@
.message-display-html-frame { .message-display-html-frame {
height: 420px; height: 420px;
background: #fff; background: var(--white);
} }
.message-display-attachments { .message-display-attachments {
@@ -2372,7 +2484,7 @@
gap: 8px; gap: 8px;
align-items: start; align-items: start;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
} }
@@ -2413,7 +2525,7 @@
display: grid; display: grid;
gap: 8px; gap: 8px;
padding: 10px; padding: 10px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-subtle); background: var(--surface-subtle);
} }
@@ -2514,7 +2626,7 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.mail-server-toggle-row { .mail-server-toggle-row {
border: 1px solid var(--line); border: var(--border-line);
border-radius: 8px; border-radius: 8px;
background: var(--panel-soft); background: var(--panel-soft);
} }
@@ -2544,7 +2656,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 24px; min-height: 24px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 99px; border-radius: 99px;
background: var(--panel-soft); background: var(--panel-soft);
padding: 3px 9px; padding: 3px 9px;
@@ -2559,6 +2671,7 @@
.form-grid.compact.mail-server-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .form-grid.compact.mail-server-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.credential-panel-grid { grid-template-columns: 1fr; }
.form-grid.compact.mail-server-form-grid { grid-template-columns: 1fr; } .form-grid.compact.mail-server-form-grid { grid-template-columns: 1fr; }
.mail-server-section-heading.split { align-items: flex-start; flex-direction: column; } .mail-server-section-heading.split { align-items: flex-start; flex-direction: column; }
.mail-server-segmented-control { width: 100%; } .mail-server-segmented-control { width: 100%; }
@@ -2588,20 +2701,20 @@
.file-drop-zone:hover, .file-drop-zone:hover,
.file-drop-zone:focus-visible, .file-drop-zone:focus-visible,
.file-drop-zone.is-active { .file-drop-zone.is-active {
border-color: #0d6efd; border-color: var(--primary);
background: rgba(13, 110, 253, .08); background: var(--primary-soft);
} }
.file-drop-zone:focus-visible { .file-drop-zone:focus-visible {
outline: none; outline: none;
box-shadow: 0 0 0 3px rgba(13, 110, 253, .18); box-shadow: var(--primary-focus-ring);
} }
.file-drop-zone[aria-disabled="true"] { .file-drop-zone[aria-disabled="true"] {
cursor: not-allowed; cursor: not-allowed;
opacity: .65; opacity: .65;
} }
.file-drop-zone.is-busy { .file-drop-zone.is-busy {
border-color: #0d6efd; border-color: var(--primary);
background: rgba(13, 110, 253, .08); background: var(--primary-soft);
cursor: progress; cursor: progress;
opacity: 1; opacity: 1;
} }
@@ -2613,7 +2726,7 @@
width: 58px; width: 58px;
height: 58px; height: 58px;
border-radius: 50%; border-radius: 50%;
background: conic-gradient(#0d6efd var(--file-drop-progress), rgba(13, 110, 253, .16) 0); background: conic-gradient(var(--primary) var(--file-drop-progress), var(--primary-soft-strong) 0);
color: var(--text-strong); color: var(--text-strong);
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
@@ -2625,14 +2738,14 @@
inset: 6px; inset: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--panel-soft); background: var(--panel-soft);
box-shadow: inset 0 0 0 1px rgba(13, 110, 253, .12); box-shadow: var(--primary-inset-ring);
} }
.file-drop-progress > span { .file-drop-progress > span {
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.file-drop-progress.is-indeterminate { .file-drop-progress.is-indeterminate {
background: conic-gradient(#0d6efd 0 32%, rgba(13, 110, 253, .16) 32% 100%); background: conic-gradient(var(--primary) 0 32%, var(--primary-soft-strong) 32% 100%);
animation: file-drop-progress-spin .85s linear infinite; animation: file-drop-progress-spin .85s linear infinite;
} }
@keyframes file-drop-progress-spin { @keyframes file-drop-progress-spin {

View File

@@ -6,7 +6,7 @@
display: grid; display: grid;
place-items: center; place-items: center;
padding: 24px; padding: 24px;
background: rgba(37, 40, 42, 0.38); background: var(--overlay-backdrop);
} }
.modal-panel { .modal-panel {
@@ -15,10 +15,10 @@
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 8px; border-radius: 8px;
background: #fff; background: var(--surface);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.26); box-shadow: var(--shadow-modal);
} }
.modal-header { .modal-header {
@@ -27,7 +27,7 @@
align-items: center; align-items: center;
flex: 0 0 auto; flex: 0 0 auto;
padding: 0 22px; padding: 0 22px;
border-bottom: 1px solid var(--line); border-bottom: var(--border-line);
} }
.modal-header h2 { .modal-header h2 {
@@ -58,7 +58,7 @@
justify-content: flex-end; justify-content: flex-end;
gap: 10px; gap: 10px;
padding: 16px 22px; padding: 16px 22px;
border-top: 1px solid var(--line); border-top: var(--border-line);
background: var(--panel-soft); background: var(--panel-soft);
} }
@@ -69,7 +69,7 @@
display: grid; display: grid;
place-items: center; place-items: center;
padding: 1.5rem; padding: 1.5rem;
background: rgba(15, 23, 42, 0.36); background: var(--dialog-backdrop);
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
@@ -79,11 +79,11 @@
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 1px solid var(--line, rgba(15, 23, 42, 0.14)); border: 1px solid var(--line);
border-radius: var(--radius-lg, 18px); border-radius: var(--radius-lg, 18px);
background: var(--surface, #fff); background: var(--surface);
color: var(--text, #172033); color: var(--text);
box-shadow: var(--shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.25)); box-shadow: var(--shadow-strong);
} }
.dialog-header { .dialog-header {
@@ -94,13 +94,13 @@
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
padding: 14px 18px; padding: 14px 18px;
border-bottom: 1px solid var(--line); border-bottom: var(--border-line);
background: var(--panel, #f7f6f4); background: var(--panel);
} }
.dialog-title { .dialog-title {
margin: 0; margin: 0;
color: var(--text-strong, #111827); color: var(--text-strong);
font-size: 1.05rem; font-size: 1.05rem;
} }
@@ -113,7 +113,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: transparent; background: transparent;
color: var(--muted, #5f6b7a); color: var(--muted);
cursor: pointer; cursor: pointer;
font-size: 1.5rem; font-size: 1.5rem;
line-height: 1; line-height: 1;
@@ -121,8 +121,8 @@
.dialog-close:hover, .dialog-close:hover,
.dialog-close:focus-visible { .dialog-close:focus-visible {
background: rgba(15, 23, 42, 0.07); background: var(--hover-tint-soft);
color: var(--text-strong, #111827); color: var(--text-strong);
outline: none; outline: none;
} }
@@ -136,7 +136,7 @@
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
padding: 20px; padding: 20px;
color: var(--text, #172033); color: var(--text);
} }
.dialog-footer { .dialog-footer {
@@ -145,8 +145,8 @@
justify-content: flex-end; justify-content: flex-end;
gap: 0.6rem; gap: 0.6rem;
padding: 12px 18px; padding: 12px 18px;
border-top: 1px solid var(--line); border-top: var(--border-line);
background: var(--panel, #f7f6f4); background: var(--panel);
} }
.confirm-dialog { .confirm-dialog {
@@ -155,12 +155,12 @@
.confirm-dialog > .dialog-header, .confirm-dialog > .dialog-header,
.confirm-dialog > .dialog-footer { .confirm-dialog > .dialog-footer {
background: var(--panel, #fff); background: var(--panel);
} }
.confirm-dialog p { .confirm-dialog p {
margin: 0; margin: 0;
color: var(--muted, #5f6b7a); color: var(--muted);
line-height: 1.5; line-height: 1.5;
} }
@@ -174,14 +174,14 @@
.admin-dialog > .dialog-header { .admin-dialog > .dialog-header {
padding: 16px 18px; padding: 16px 18px;
border-bottom: 1px solid var(--line); border-bottom: var(--border-line);
background: var(--panel); background: var(--panel);
} }
.admin-dialog > .dialog-footer { .admin-dialog > .dialog-footer {
flex: 0 0 auto; flex: 0 0 auto;
padding: 12px 18px; padding: 12px 18px;
border-top: 1px solid var(--line); border-top: var(--border-line);
background: var(--panel); background: var(--panel);
box-shadow: 0 -8px 24px rgba(15, 23, 42, .06); box-shadow: var(--shadow-footer);
} }

View File

@@ -1,9 +1,9 @@
.form-grid { display: grid; gap: 18px; } .form-grid { display: grid; gap: 18px; }
.form-grid.compact { grid-template-columns: 1fr 1fr; } .form-grid.compact { grid-template-columns: 1fr 1fr; }
.form-field { display: grid; gap: 7px; } .form-field { display: grid; gap: 7px; }
.form-label { font-weight: 700; font-size: 13px; color: #6b6863; } .form-label { font-weight: 700; font-size: 13px; color: var(--text-label); }
.form-help { font-size: 12px; color: var(--muted); } .form-help { font-size: 12px; color: var(--muted); }
input, select, textarea { border: 1px solid var(--line); border-radius: 5px; background: #fff; font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: inset 0 1px 2px rgba(0,0,0,.04); } input, select, textarea { border: var(--border-line); border-radius: 5px; background: var(--surface); font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: var(--shadow-control-inset); }
textarea { resize: vertical; } textarea { resize: vertical; }
.form-field:has(input:disabled, select:disabled, textarea:disabled, input[readonly], textarea[readonly]) .form-label { color: var(--control-disabled-label); } .form-field:has(input:disabled, select:disabled, textarea:disabled, input[readonly], textarea[readonly]) .form-label { color: var(--control-disabled-label); }
input:disabled:not([type="checkbox"]):not([type="radio"]), input:disabled:not([type="checkbox"]):not([type="radio"]),
@@ -14,7 +14,7 @@ textarea[readonly] {
border-color: var(--control-disabled-border); border-color: var(--control-disabled-border);
background: var(--control-disabled-bg); background: var(--control-disabled-bg);
color: var(--control-disabled-text); color: var(--control-disabled-text);
box-shadow: inset 0 1px 0 rgba(255,255,255,.32); box-shadow: var(--shadow-inset-highlight-muted);
opacity: 1; opacity: 1;
} }
input:disabled:not([type="checkbox"]):not([type="radio"]), input:disabled:not([type="checkbox"]):not([type="radio"]),
@@ -33,8 +33,8 @@ textarea[readonly]::placeholder {
color: var(--control-disabled-placeholder); color: var(--control-disabled-placeholder);
opacity: 1; opacity: 1;
} }
.btn { border: 1px solid var(--line-dark); border-radius: 4px; padding: 9px 14px; font: inherit; font-weight: 700; cursor: pointer; background: #f8f8f7; color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,.15); } .btn { border: var(--border-line-dark); border-radius: 4px; padding: 9px 14px; font: inherit; font-weight: 700; cursor: pointer; background: var(--control-bg); color: var(--text); box-shadow: 0 1px 2px var(--hover-tint-strong); }
.btn-primary { background: var(--green); border-color: #5aa99b; color: #fff; } .btn-primary { background: var(--green); border-color: var(--success-border); color: var(--on-accent); }
.btn-ghost { border-color: transparent; background: transparent; box-shadow: none; } .btn-ghost { border-color: transparent; background: transparent; box-shadow: none; }
.btn-danger { background: var(--red); color: #fff; border-color: #c94d43; } .btn-danger { background: var(--red); color: var(--on-accent); border-color: var(--danger-border-strong); }
.btn:disabled { opacity: .55; cursor: not-allowed; } .btn:disabled { opacity: .55; cursor: not-allowed; }

View File

@@ -1,6 +1,8 @@
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: 58px 1fr; overflow: hidden; } .app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: auto 1fr; overflow: hidden; }
.icon-rail { background: var(--rail-bg); color: #c7c6c0; display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: inset -1px 0 rgba(0,0,0,.35); z-index: 1000; } .icon-rail { width: 58px; background: var(--rail-bg); color: var(--rail-text); display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: var(--shadow-rail); z-index: 1000; transition: width .16s ease; }
.brand-mark { width: 34px; height: 34px; margin: 15px 0 14px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); color: transparent; font-size: 0; position: relative; } .icon-rail.expanded { width: 208px; align-items: stretch; }
.icon-rail-header { width: 100%; min-height: 63px; display: flex; align-items: center; justify-content: center; padding: 0 10px; box-sizing: border-box; }
.brand-mark { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); color: transparent; font-size: 0; position: relative; }
.brand-mark::after { position: absolute; .brand-mark::after { position: absolute;
top: 9px; top: 9px;
left: 9px; left: 9px;
@@ -10,18 +12,32 @@
content: ""; content: "";
background-color: var(--rail-bg); background-color: var(--rail-bg);
} }
.icon-nav { width: 100%; display: flex; flex-direction: column; } .icon-rail-toggle { border: 0; background: transparent; font: inherit; cursor: pointer; padding: 0; }
.icon-nav-item { height: 52px; display: grid; place-items: center; color: #a7a49f; border-left: 3px solid transparent; text-decoration: none; } .icon-rail-toggle:hover,
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: #fff; border-left-color: var(--accent); } .icon-rail-toggle:focus-visible { background: var(--rail-bg-active); color: var(--on-accent); outline: none; }
.icon-rail-toggle:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.icon-nav { width: 100%; display: flex; flex-direction: column; min-width: 0; }
.icon-nav-item { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; color: var(--rail-text-muted); border-left: 3px solid transparent; text-decoration: none; box-sizing: border-box; }
.icon-nav-item svg,
.icon-nav-item > :first-child:not(.icon-nav-label) { justify-self: center; }
.icon-nav-fallback { justify-self: center; font-weight: 800; text-transform: uppercase; }
.icon-nav-label { display: none; min-width: 0; overflow: hidden; padding-right: 14px; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.icon-rail.expanded .icon-nav-label { display: block; }
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: var(--on-accent); border-left-color: var(--accent); }
.icon-rail.compact { width: 58px; }
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); } .app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
.titlebar { position: relative; background: #fbfbfa; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: 0px 0px 10px 0px darkgrey; } .titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: var(--shadow-chrome); }
.tenant-selector { height: 40px; min-width: 210px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: #fff; display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(0,0,0,.05); } .tenant-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); }
.tenant-label, .tenant-caret, .muted { color: var(--muted); } .tenant-label, .tenant-caret, .muted { color: var(--muted); }
.titlebar-spacer { flex: 1; } .titlebar-spacer { flex: 1; }
.titlebar-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; } .titlebar-link, .titlebar-icon-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; }
.titlebar-icon-link { width: 34px; height: 34px; justify-content: center; border-radius: 4px; cursor: pointer; }
.titlebar-icon-link:hover, .titlebar-link:hover { background: var(--titlebar-hover-bg); color: var(--text-strong); }
.titlebar-notification-button { position: relative; }
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
.account-pill { color: var(--text); } .account-pill { color: var(--text); }
.maintenance-topbar-link { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border: 1px solid rgba(154, 92, 0, .26); background: #ffe1a3; color: #593700; border-radius: 6px; min-height: 32px; padding: 0 14px; font: inherit; font-weight: 800; box-shadow: 0 1px 2px rgba(0,0,0,.08); cursor: pointer; z-index: 1; } .maintenance-topbar-link { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); border-radius: 6px; min-height: 32px; padding: 0 14px; font: inherit; font-weight: 800; box-shadow: 0 1px 2px var(--hover-tint); cursor: pointer; z-index: 1; }
.maintenance-topbar-link:hover { background: #ffd170; color: #3e2800; } .maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; } .language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; } .language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
.language-menu { min-width: 210px; } .language-menu { min-width: 210px; }
@@ -29,19 +45,19 @@
.language-menu .dropdown-item svg { margin-left: auto; } .language-menu .dropdown-item svg { margin-left: auto; }
.language-option-code { width: 34px; color: var(--muted); } .language-option-code { width: 34px; color: var(--muted); }
.api-mini { display: flex; gap: 6px; } .api-mini { display: flex; gap: 6px; }
.api-mini input { width: 155px; height: 30px; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 0 8px; } .api-mini input { width: 155px; height: 30px; border: var(--border-line); border-radius: var(--radius-sm); padding: 0 8px; }
.breadcrumb-bar { background: var(--bar); border-bottom: 1px solid var(--line-dark); display: flex; align-items: center; padding: 0 22px; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 90; } .breadcrumb-bar { background: var(--bar); border-bottom: var(--border-line-dark); display: flex; align-items: center; padding: 0 22px; box-shadow: var(--shadow-chrome); z-index: 90; }
.breadcrumbs { display: flex; gap: 6px; text-transform: uppercase; font-weight: 700; font-size: 13px; color: #615f5c; } .breadcrumbs { display: flex; gap: 6px; text-transform: uppercase; font-weight: 700; font-size: 13px; color: var(--text-breadcrumb); }
.crumb { display: inline-flex; align-items: center; gap: 4px; } .crumb { display: inline-flex; align-items: center; gap: 4px; }
.breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; } .breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; }
.ghost-button { border: 0; background: transparent; color: #77736d; font-weight: 700; } .ghost-button { border: 0; background: transparent; color: var(--muted); font-weight: 700; }
.app-content { min-height: 0; overflow: hidden; } .app-content { min-height: 0; overflow: hidden; }
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); } .workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
.section-sidebar { background: #c8c4bf; border-right: 1px solid var(--line-dark); padding: 18px 0; border-left: 3px solid #afada9; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 80; overflow-y: scroll; } .section-sidebar { background: var(--sidebar-bg); border-right: var(--border-line-dark); padding: 18px 0; border-left: 3px solid var(--sidebar-border); box-shadow: var(--shadow-chrome); z-index: 80; overflow-y: scroll; }
.section-title { font-size: 12px; font-weight: 800; color: #7f7b75; padding: 0 22px 14px; letter-spacing: .06em; } .section-title { font-size: 12px; font-weight: 800; color: var(--muted); padding: 0 22px 14px; letter-spacing: .06em; }
.section-title-lower { margin-top: 28px; } .section-title-lower { margin-top: 28px; }
.section-link { width: calc(100% + 3px); height: 48px; border: 0; padding: 0 10px 0 22px; background: transparent; text-align: left; color: #686560; font: inherit; cursor: pointer; margin-left: -3px; } .section-link { width: calc(100% + 3px); height: 48px; border: 0; padding: 0 10px 0 22px; background: transparent; text-align: left; color: var(--text-subtle); font: inherit; cursor: pointer; margin-left: -3px; }
.section-link:hover, .section-link.active { background: rgba(255,255,255,.35); color: #3e3e3f; } .section-link:hover, .section-link.active { background: var(--sidebar-hover-bg); color: var(--text-hover-strong); }
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; } .section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
.section-link.subtle { font-size: 13px; } .section-link.subtle { font-size: 13px; }
.workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; } .workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; }
@@ -64,49 +80,49 @@
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 75; z-index: 75;
background: var(--bg, #f8f7f4); background: var(--bg);
border-bottom: 1px solid var(--line); border-bottom: var(--border-line);
box-shadow: 0 8px 18px rgba(65, 60, 52, .08); box-shadow: var(--shadow-header);
margin: -28px -34px 22px; margin: -28px -34px 22px;
padding: 18px 34px 16px; padding: 18px 34px 16px;
} }
.workspace-heading .mono-small { margin-top: 8px; } .workspace-heading .mono-small { margin-top: 8px; }
.panel, .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; } .panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.card-header { min-height: 56px; padding: 0 24px; border-bottom: 1px solid var(--line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } .card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); } .card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;} .card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
.card-body { padding: 22px 24px; } .card-body { padding: 22px 24px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 12px; margin-bottom: 18px; } .metric-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 12px; margin-bottom: 18px; }
.metric-grid.inside { margin: 14px 0; } .metric-grid.inside { margin: 14px 0; }
.metric-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); } .metric-card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); }
.metric-good { border-top-color: var(--green); } .metric-warning { border-top-color: var(--amber); } .metric-danger { border-top-color: var(--red); } .metric-info { border-top-color: var(--blue); } .metric-good { border-top-color: var(--green); } .metric-warning { border-top-color: var(--amber); } .metric-danger { border-top-color: var(--red); } .metric-info { border-top-color: var(--blue); }
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; } .metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
.metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; } .metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; }
.metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; } .metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; }
.dashboard-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; align-items: start; } .dashboard-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; align-items: start; }
.wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; } .wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; }
.wizard-card { width: min(980px, 100%); background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; } .wizard-card { width: min(980px, 100%); background: var(--panel); border: var(--border-line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; }
.wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; } .wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; }
.wizard-heading { display: flex; justify-content: space-between; margin-bottom: 18px; } .wizard-heading { display: flex; justify-content: space-between; margin-bottom: 18px; }
.wizard-heading h1 { margin: 0; } .wizard-heading h1 { margin: 0; }
.save-state { color: var(--green); font-size: 13px; font-weight: 700; } .save-state { color: var(--green); font-size: 13px; font-weight: 700; }
.wizard-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; } .wizard-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
.stepper { list-style: none; padding: 22px 0; margin: 0; background: #f6f5f3; border-right: 1px solid var(--line); } .stepper { list-style: none; padding: 22px 0; margin: 0; background: var(--panel-soft); border-right: var(--border-line); }
.step button { width: 100%; min-height: 72px; border: 0; background: transparent; display: flex; gap: 14px; text-align: left; padding: 12px 20px; color: #999; cursor: pointer; } .step button { width: 100%; min-height: 72px; border: 0; background: transparent; display: flex; gap: 14px; text-align: left; padding: 12px 20px; color: var(--muted); cursor: pointer; }
.step.active button { background: #fff; color: var(--text-strong); } .step.active button { background: var(--surface); color: var(--text-strong); }
.step-number { width: 32px; height: 32px; border-radius: 50%; background: #d8d6d2; color: #fff; display: grid; place-items: center; font-weight: 800; flex: 0 0 auto; } .step-number { width: 32px; height: 32px; border-radius: 50%; background: var(--step-number-bg); color: var(--on-accent); display: grid; place-items: center; font-weight: 800; flex: 0 0 auto; }
.step.active .step-number { background: var(--accent); } .step.active .step-number { background: var(--accent); }
.step small { display: block; margin-top: 3px; color: var(--muted); } .step small { display: block; margin-top: 3px; color: var(--muted); }
.step-intro h2 { margin: 0; color: var(--text-strong); } .step-intro h2 { margin: 0; color: var(--text-strong); }
.step-intro p { margin-top: 6px; color: var(--muted); } .step-intro p { margin-top: 6px; color: var(--muted); }
.button-row { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; } .button-row { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; }
.alert { padding: 14px 16px; border-radius: var(--radius-sm); margin-bottom: 16px; } .alert { padding: 14px 16px; border-radius: var(--radius-sm); margin-bottom: 16px; }
.alert.warning { background: #ffe1a3; } .alert.danger { background: #f3c5be; } .alert.warning { background: var(--warning-bg); } .alert.danger { background: var(--danger-bg); }
.table-like { border: 1px solid var(--line); } .table-like { border: var(--border-line); }
.table-row-link { display: grid; grid-template-columns: 1fr auto; text-decoration: none; color: var(--text); padding: 14px 16px; border-bottom: 1px solid var(--line); } .table-row-link { display: grid; grid-template-columns: 1fr auto; text-decoration: none; color: var(--text); padding: 14px 16px; border-bottom: var(--border-line); }
.table-row-link:hover { background: var(--panel-soft); } .table-row-link:hover { background: var(--panel-soft); }
.mono-small { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); } .mono-small { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); }
.code-panel { background: #292929; color: #f1f1f1; padding: 18px; border-radius: 4px; overflow: auto; } .code-panel { background: var(--code-bg); color: var(--code-text); padding: 18px; border-radius: 4px; overflow: auto; }
@media (max-width: 900px) { @media (max-width: 900px) {
.api-mini { display: none; } .api-mini { display: none; }
.workspace { grid-template-columns: 1fr; } .workspace { grid-template-columns: 1fr; }
@@ -119,29 +135,29 @@
/* Layout additions: login and help dropdown */ /* Layout additions: login and help dropdown */
.tenant-selector.disabled { opacity: .86; cursor: default; } .tenant-selector.disabled { opacity: .86; cursor: default; }
.context-menu-wrap { position: relative; } .context-menu-wrap { position: relative; }
.dropdown-menu { position: absolute; right: 0; top: calc(100% + 10px); min-width: 230px; background: #fff; border: 1px solid var(--line); border-radius: 6px; box-shadow: var(--shadow-menu); padding: 8px; z-index: 5000; } .dropdown-menu { position: absolute; right: 0; top: calc(100% + 10px); min-width: 230px; background: var(--surface); border: var(--border-line); border-radius: 6px; box-shadow: var(--shadow-menu); padding: 8px; z-index: 5000; }
.dropdown-menu hr { border: 0; border-top: 1px solid var(--line); margin: 8px 0; } .dropdown-menu hr { border: 0; border-top: var(--border-line); margin: 8px 0; }
.dropdown-item { width: 100%; min-height: 34px; border: 0; background: transparent; display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 4px; text-align: left; color: var(--text); font: inherit; text-decoration: none; cursor: pointer; } .dropdown-item { width: 100%; min-height: 34px; border: 0; background: transparent; display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 4px; text-align: left; color: var(--text); font: inherit; text-decoration: none; cursor: pointer; }
.dropdown-item:hover { background: var(--panel-soft); color: var(--text-strong); } .dropdown-item:hover { background: var(--panel-soft); color: var(--text-strong); }
.dropdown-item small { margin-left: auto; color: var(--muted); } .dropdown-item small { margin-left: auto; color: var(--muted); }
.account-menu { min-width: 260px; } .account-menu { min-width: 260px; }
.account-menu-header { padding: 8px 9px 10px; border-bottom: 1px solid var(--line); margin-bottom: 8px; } .account-menu-header { padding: 8px 9px 10px; border-bottom: var(--border-line); margin-bottom: 8px; }
.account-menu-header strong { display: block; color: var(--text-strong); } .account-menu-header strong { display: block; color: var(--text-strong); }
.account-menu-header span { color: var(--muted); font-size: 13px; } .account-menu-header span { color: var(--muted); font-size: 13px; }
.login-hint { background: #f6f5f3; border: 1px solid var(--line); padding: 10px 12px; border-radius: 4px; color: var(--muted); font-size: 13px; } .login-hint { background: var(--panel-soft); border: var(--border-line); padding: 10px 12px; border-radius: 4px; color: var(--muted); font-size: 13px; }
.help-panel-section { margin-bottom: 16px; } .help-panel-section { margin-bottom: 16px; }
.help-panel-section h3 { margin: 0 0 7px; color: var(--text-strong); } .help-panel-section h3 { margin: 0 0 7px; color: var(--text-strong); }
.about-logo { width: 52px; height: 52px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); margin-bottom: 12px; } .about-logo { width: 52px; height: 52px; border-radius: 50%; background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); margin-bottom: 12px; }
.kbd { display: inline-flex; min-width: 22px; height: 22px; align-items: center; justify-content: center; border: 1px solid var(--line-dark); border-bottom-width: 2px; border-radius: 4px; padding: 0 6px; background: #fff; font-size: 12px; font-weight: 700; color: #666; } .kbd { display: inline-flex; min-width: 22px; height: 22px; align-items: center; justify-content: center; border: var(--border-line-dark); border-bottom-width: 2px; border-radius: 4px; padding: 0 6px; background: var(--surface); font-size: 12px; font-weight: 700; color: var(--text-soft); }
.titlebar .tenant-selector strong { max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .titlebar .tenant-selector strong { max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Campaign workspace polish */ /* Campaign workspace polish */
.crumb-link { color: inherit; text-decoration: none; border-radius: 4px; padding: 4px 5px; margin: -4px -5px; } .crumb-link { color: inherit; text-decoration: none; border-radius: 4px; padding: 4px 5px; margin: -4px -5px; }
.crumb-link:hover { background: rgba(255,255,255,.35); color: var(--text-strong); } .crumb-link:hover { background: var(--sidebar-hover-bg); color: var(--text-strong); }
.compact-actions { margin: 0; align-items: center; } .compact-actions { margin: 0; align-items: center; }
.below-grid { margin-top: 18px; } .below-grid { margin-top: 18px; }
.detail-list { display: grid; gap: 12px; margin: 0; } .detail-list { display: grid; gap: 12px; margin: 0; }
.detail-list div { display: grid; grid-template-columns: 145px minmax(0, 1fr); align-items: center; gap: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--line); } .detail-list div { display: grid; grid-template-columns: 145px minmax(0, 1fr); align-items: center; gap: 18px; padding-bottom: 12px; border-bottom: var(--border-line); }
.detail-list div:last-child { border-bottom: 0; padding-bottom: 0; } .detail-list div:last-child { border-bottom: 0; padding-bottom: 0; }
.detail-list dt { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; } .detail-list dt { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; }
.detail-list dd { margin: 0; min-width: 0; } .detail-list dd { margin: 0; min-width: 0; }
@@ -152,7 +168,7 @@
.next-action-warning { border-left-color: var(--amber); } .next-action-warning { border-left-color: var(--amber); }
.next-action-info { border-left-color: var(--blue); } .next-action-info { border-left-color: var(--blue); }
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; } .summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
.summary-tile { background: var(--panel-soft); border: 1px solid var(--line); border-radius: 6px; padding: 14px; } .summary-tile { background: var(--panel-soft); border: var(--border-line); border-radius: 6px; padding: 14px; }
.summary-tile span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; } .summary-tile span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; }
.summary-tile strong { display: block; margin-top: 6px; color: var(--text-strong); font-size: 24px; } .summary-tile strong { display: block; margin-top: 6px; color: var(--text-strong); font-size: 24px; }
.empty-state { min-height: 220px; display: grid; place-items: center; text-align: center; padding: 32px; } .empty-state { min-height: 220px; display: grid; place-items: center; text-align: center; padding: 32px; }
@@ -168,23 +184,23 @@
.docs-tree-node { min-width: 0; } .docs-tree-node { min-width: 0; }
.docs-tree-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: start; gap: 3px; } .docs-tree-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: start; gap: 3px; }
.docs-tree-toggle, .docs-tree-toggle-placeholder { width: 22px; height: 32px; display: inline-grid; place-items: center; flex: 0 0 auto; } .docs-tree-toggle, .docs-tree-toggle-placeholder { width: 22px; height: 32px; display: inline-grid; place-items: center; flex: 0 0 auto; }
.docs-tree-toggle { border: 0; border-radius: 4px; background: transparent; color: #686560; cursor: pointer; } .docs-tree-toggle { border: 0; border-radius: 4px; background: transparent; color: var(--text-subtle); cursor: pointer; }
.docs-tree-toggle:hover { background: rgba(255,255,255,.35); color: var(--text-strong); } .docs-tree-toggle:hover { background: var(--sidebar-hover-bg); color: var(--text-strong); }
.docs-tree-toggle-placeholder { opacity: 0; } .docs-tree-toggle-placeholder { opacity: 0; }
.docs-tree-children { display: grid; gap: 2px; } .docs-tree-children { display: grid; gap: 2px; }
.docs-tree-page { width: 100%; min-height: 32px; border: 0; border-radius: 4px; background: transparent; color: #686560; font: inherit; font-size: 13px; line-height: 1.25; text-align: left; cursor: pointer; padding: 7px 9px; overflow-wrap: anywhere; } .docs-tree-page { width: 100%; min-height: 32px; border: 0; border-radius: 4px; background: transparent; color: var(--text-subtle); font: inherit; font-size: 13px; line-height: 1.25; text-align: left; cursor: pointer; padding: 7px 9px; overflow-wrap: anywhere; }
.docs-tree-page:hover, .docs-tree-page.is-active { background: rgba(255,255,255,.42); color: var(--text-strong); } .docs-tree-page:hover, .docs-tree-page.is-active { background: var(--sidebar-hover-bg-strong); color: var(--text-strong); }
.docs-tree-page.is-active { box-shadow: inset 3px 0 var(--accent); font-weight: 700; } .docs-tree-page.is-active { box-shadow: inset 3px 0 var(--accent); font-weight: 700; }
.docs-tree-empty { color: var(--muted); font-size: 12px; line-height: 1.35; padding: 8px 9px; } .docs-tree-empty { color: var(--muted); font-size: 12px; line-height: 1.35; padding: 8px 9px; }
.docs-page { min-height: 100%; background: var(--panel); } .docs-page { min-height: 100%; background: var(--panel); }
.docs-content { width: min(1180px, 100%); margin: 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) 260px; align-items: start; gap: 32px; } .docs-content { width: min(1180px, 100%); margin: 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) 260px; align-items: start; gap: 32px; }
.docs-page-main { min-width: 0; } .docs-page-main { min-width: 0; }
.docs-page-outline-box { position: sticky; top: 24px; border: 1px solid var(--line); border-radius: 6px; background: #fbfaf8; padding: 16px; box-shadow: 0 1px 2px rgba(0,0,0,.05); } .docs-page-outline-box { position: sticky; top: 24px; border: var(--border-line); border-radius: 6px; background: var(--surface-raised); padding: 16px; box-shadow: var(--shadow-xs); }
.docs-page-outline-box h2 { margin: 0 0 10px; color: var(--text-strong); font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; } .docs-page-outline-box h2 { margin: 0 0 10px; color: var(--text-strong); font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
.docs-page-outline-box nav { display: grid; gap: 4px; } .docs-page-outline-box nav { display: grid; gap: 4px; }
.docs-page-outline-box a { border-radius: 4px; color: var(--muted); font-size: 13px; line-height: 1.3; padding: 5px 6px; text-decoration: none; } .docs-page-outline-box a { border-radius: 4px; color: var(--muted); font-size: 13px; line-height: 1.3; padding: 5px 6px; text-decoration: none; }
.docs-page-outline-box a:hover { background: var(--panel); color: var(--text-strong); } .docs-page-outline-box a:hover { background: var(--panel); color: var(--text-strong); }
.docs-section { scroll-margin-top: 24px; padding-bottom: 34px; border-bottom: 1px solid var(--line); } .docs-section { scroll-margin-top: 24px; padding-bottom: 34px; border-bottom: var(--border-line); }
.docs-section:last-child { border-bottom: 0; } .docs-section:last-child { border-bottom: 0; }
.docs-section h2 { margin: 0 0 18px; color: var(--text-strong); font-size: 24px; font-weight: 650; } .docs-section h2 { margin: 0 0 18px; color: var(--text-strong); font-size: 24px; font-weight: 650; }
.docs-reference-block { scroll-margin-top: 24px; margin-top: 24px; } .docs-reference-block { scroll-margin-top: 24px; margin-top: 24px; }
@@ -221,3 +237,6 @@
.icon-rail-bottom .icon-nav-item { .icon-rail-bottom .icon-nav-item {
width: 100%; width: 100%;
} }
.icon-rail-bottom .icon-rail-toggle {
border-top: 1px solid var(--rail-bg-active);
}

View File

@@ -1,7 +1,7 @@
/* Legacy mapping / rule tables used by early wizard surfaces. */ /* Legacy mapping / rule tables used by early wizard surfaces. */
.mapping-table { .mapping-table {
margin-top: 18px; margin-top: 18px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
} }
@@ -13,7 +13,7 @@
} }
.mapping-header { .mapping-header {
background: var(--bar); background: var(--bar);
color: #6b6863; color: var(--text-label);
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
text-transform: uppercase; text-transform: uppercase;
@@ -21,16 +21,16 @@
.mapping-header span, .mapping-header span,
.mapping-row span { .mapping-row span {
padding: 12px 14px; padding: 12px 14px;
border-right: 1px solid var(--line); border-right: var(--border-line);
} }
.mapping-row { .mapping-row {
background: #fff; background: var(--surface);
border-top: 1px solid var(--line); border-top: var(--border-line);
} }
.attachment-rule-card { .attachment-rule-card {
margin: 18px 0; margin: 18px 0;
background: #fff; background: var(--surface);
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
padding: 18px; padding: 18px;
} }
@@ -48,11 +48,11 @@
.app-table { .app-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: #fff; background: var(--surface);
} }
.app-table thead { .app-table thead {
background: var(--bar); background: var(--bar);
color: #625f5a; color: var(--text-table-heading);
font-size: 12px; font-size: 12px;
letter-spacing: .04em; letter-spacing: .04em;
text-transform: uppercase; text-transform: uppercase;
@@ -60,7 +60,7 @@
.app-table th { .app-table th {
height: 40px; height: 40px;
padding: 0 16px; padding: 0 16px;
border-right: 1px solid var(--line-dark); border-right: var(--border-line-dark);
font-weight: 800; font-weight: 800;
text-align: left; text-align: left;
white-space: nowrap; white-space: nowrap;
@@ -69,7 +69,7 @@
.app-table td { .app-table td {
min-height: 56px; min-height: 56px;
padding: 16px; padding: 16px;
border-top: 1px solid var(--line); border-top: var(--border-line);
vertical-align: middle; vertical-align: middle;
} }
.app-table tbody tr:hover { background: var(--panel-soft); } .app-table tbody tr:hover { background: var(--panel-soft); }
@@ -98,11 +98,11 @@
table-layout: fixed; table-layout: fixed;
} }
.campaign-table thead tr { .campaign-table thead tr {
box-shadow: 0 12px 16px -18px rgba(45, 43, 40, .8); box-shadow: var(--shadow-data-header);
} }
.campaign-table thead th { .campaign-table thead th {
background: var(--bar); background: var(--bar);
border-right: 1px solid var(--line-dark); border-right: var(--border-line-dark);
} }
.campaign-table th:nth-child(1), .campaign-table th:nth-child(1),
.campaign-table td:nth-child(1) { width: auto; min-width: 360px; } .campaign-table td:nth-child(1) { width: auto; min-width: 360px; }
@@ -115,7 +115,7 @@
.campaign-table th:nth-child(5), .campaign-table th:nth-child(5),
.campaign-table td:nth-child(5) { width: 105px; text-align: right; } .campaign-table td:nth-child(5) { width: 105px; text-align: right; }
.campaign-table td.updated-cell { .campaign-table td.updated-cell {
color: #696660; color: var(--text-subtle);
font-size: 12px; font-size: 12px;
line-height: 1.35; line-height: 1.35;
white-space: nowrap; white-space: nowrap;
@@ -132,7 +132,7 @@
width: 100%; width: 100%;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
} }
@@ -156,7 +156,7 @@
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
box-shadow: var(--shadow-card, 0 2px 10px rgba(0,0,0,.06)); box-shadow: var(--shadow-card);
} }
.card-body > .admin-table-surface:only-child { .card-body > .admin-table-surface:only-child {
@@ -244,8 +244,8 @@
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
padding: 11px 12px; padding: 11px 12px;
border-right: 1px solid rgba(189, 184, 176, 0.32); border-right: var(--border-muted-line);
border-bottom: 1px solid var(--border-soft, var(--line)); border-bottom: var(--border-line);
background: var(--surface); background: var(--surface);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -279,8 +279,8 @@
top: 0; top: 0;
z-index: 4; z-index: 4;
background: var(--bar); background: var(--bar);
border-bottom: 1px solid var(--line-dark); border-bottom: var(--border-line-dark);
color: #625f5a; color: var(--text-table-heading);
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
letter-spacing: .04em; letter-spacing: .04em;
@@ -293,7 +293,7 @@
} }
.data-grid-body-cell.data-grid-row-odd { .data-grid-body-cell.data-grid-row-odd {
background: #fbfaf8; background: var(--surface-raised);
} }
.data-grid-body-cell.data-grid-row-even { .data-grid-body-cell.data-grid-row-even {
@@ -359,7 +359,7 @@
.data-grid-filter-trigger { .data-grid-filter-trigger {
border: 0; border: 0;
background: transparent; background: transparent;
color: rgba(98, 95, 90, .68); color: var(--text-table-heading-muted);
padding: 0; padding: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -384,20 +384,20 @@
.data-grid-filter-trigger:focus-visible, .data-grid-filter-trigger:focus-visible,
.data-grid-filter-trigger.is-open { .data-grid-filter-trigger.is-open {
color: var(--text-strong); color: var(--text-strong);
background: rgba(255, 255, 255, .45); background: var(--panel-glass-muted);
outline: none; outline: none;
} }
.data-grid-filter-trigger.has-filter { .data-grid-filter-trigger.has-filter {
color: var(--text-strong); color: var(--text-strong);
background: rgba(255, 255, 255, .62); background: var(--panel-glass);
box-shadow: inset 0 0 0 1px rgba(98, 95, 90, .24); box-shadow: inset 0 0 0 1px var(--text-table-heading-muted-border);
} }
.data-grid-filter-popover { .data-grid-filter-popover {
position: fixed; position: fixed;
z-index: 12000; z-index: 12000;
border: 1px solid var(--line-dark); border: var(--border-line-dark);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
box-shadow: var(--shadow-menu); box-shadow: var(--shadow-menu);
@@ -458,9 +458,9 @@
.data-grid-filter-field input, .data-grid-filter-field input,
.data-grid-filter-field select { .data-grid-filter-field select {
width: 100%; width: 100%;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: #fff; background: var(--surface);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-weight: 500; font-weight: 500;
@@ -496,7 +496,7 @@
padding: 20px; padding: 20px;
color: var(--muted); color: var(--muted);
background: var(--surface); background: var(--surface);
border-bottom: 1px solid var(--line); border-bottom: var(--border-line);
} }
.data-grid-cell.is-sticky-start, .data-grid-cell.is-sticky-start,
@@ -507,19 +507,19 @@
} }
.data-grid-cell.is-sticky-start { .data-grid-cell.is-sticky-start {
border-right: 1px solid var(--line-dark); border-right: var(--border-line-dark);
box-shadow: 8px 0 14px -14px rgba(45, 43, 40, .6); box-shadow: var(--shadow-sticky-start);
} }
.data-grid-cell.is-sticky-end { .data-grid-cell.is-sticky-end {
border-left: 1px solid var(--line-dark); border-left: var(--border-line-dark);
box-shadow: -8px 0 14px -14px rgba(45, 43, 40, .6); box-shadow: var(--shadow-sticky-end);
} }
.data-grid-header-cell.is-sticky-start, .data-grid-header-cell.is-sticky-start,
.data-grid-header-cell.is-sticky-end { .data-grid-header-cell.is-sticky-end {
z-index: 5; z-index: 5;
border-bottom: 1px solid var(--line-dark); border-bottom: var(--border-line-dark);
background: var(--bar); background: var(--bar);
} }
@@ -534,9 +534,9 @@
.data-grid-list-select { .data-grid-list-select {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: #fff; background: var(--surface);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
padding: 6px 28px 6px 8px; padding: 6px 28px 6px 8px;
@@ -580,9 +580,9 @@
.data-grid-list-filter-options { .data-grid-list-filter-options {
max-height: 230px; max-height: 230px;
overflow: auto; overflow: auto;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: #fff; background: var(--surface);
} }
.data-grid-list-filter-row { .data-grid-list-filter-row {
@@ -591,7 +591,7 @@
gap: 8px; gap: 8px;
min-height: 38px; min-height: 38px;
padding: 6px 8px; padding: 6px 8px;
border-bottom: 1px solid var(--line); border-bottom: var(--border-line);
} }
.data-grid-list-filter-row:last-child { .data-grid-list-filter-row:last-child {
@@ -645,9 +645,9 @@
.data-grid-list-option-add input { .data-grid-list-option-add input {
width: 100%; width: 100%;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: #fff; background: var(--surface);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
padding: 8px 10px; padding: 8px 10px;
@@ -694,7 +694,7 @@
.data-grid-pagination { .data-grid-pagination {
min-height: 52px; min-height: 52px;
padding: 8px 12px; padding: 8px 12px;
border-top: 1px solid var(--border-soft, var(--line)); border-top: 1px solid var(--line);
background: var(--panel); background: var(--panel);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -731,7 +731,7 @@
.data-grid-page-controls button { .data-grid-page-controls button {
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 1px solid var(--border); border: var(--border-line);
border-radius: var(--radius-xs, 5px); border-radius: var(--radius-xs, 5px);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
@@ -742,7 +742,7 @@
.data-grid-page-controls button:hover:not(:disabled), .data-grid-page-controls button:hover:not(:disabled),
.data-grid-page-controls button:focus-visible { .data-grid-page-controls button:focus-visible {
border-color: var(--line-dark); border-color: var(--line-dark);
background: var(--surface-strong, #f5f3ef); background: var(--surface-strong);
} }
.data-grid-page-controls button:disabled { .data-grid-page-controls button:disabled {
@@ -765,7 +765,7 @@
width: 100%; width: 100%;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
} }
@@ -779,7 +779,7 @@
.connection-tree-header { .connection-tree-header {
min-height: 40px; min-height: 40px;
border-bottom: 1px solid var(--line-dark); border-bottom: var(--border-line-dark);
background: var(--bar); background: var(--bar);
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
@@ -789,7 +789,7 @@
.connection-tree-row { .connection-tree-row {
min-height: 58px; min-height: 58px;
border-bottom: 1px solid var(--line); border-bottom: var(--border-line);
background: var(--surface); background: var(--surface);
} }
@@ -806,7 +806,7 @@
align-items: center; align-items: center;
min-width: 0; min-width: 0;
padding: 10px 12px; padding: 10px 12px;
border-right: 1px solid var(--line); border-right: var(--border-line);
} }
.connection-tree-cell:last-child { .connection-tree-cell:last-child {

View File

@@ -1,7 +1,16 @@
:root { :root {
color-scheme: light;
--white: #ffffff;
--black: #000000;
--transparent: transparent;
--rail-bg: #25282a; --rail-bg: #25282a;
--rail-bg-active: #1c1e20; --rail-bg-active: #1c1e20;
--rail-text: #c7c6c0;
--rail-text-muted: #a7a49f;
--accent: #ef6b3a; --accent: #ef6b3a;
--accent-rgb: 239, 107, 58;
--accent-soft: rgba(239, 107, 58, .35); --accent-soft: rgba(239, 107, 58, .35);
--bg: #e9e7e4; --bg: #e9e7e4;
@@ -11,48 +20,242 @@
--panel-header: #ffffff; --panel-header: #ffffff;
--surface: #ffffff; --surface: #ffffff;
--surface-subtle: var(--panel-soft); --surface-subtle: var(--panel-soft);
--surface-muted: #f6f7f9;
--surface-strong: #f5f3ef;
--surface-raised: #fbfaf8;
--titlebar-bg: #fbfbfa;
--sidebar-bg: #c8c4bf;
--sidebar-border: #afada9;
--sidebar-hover-bg: rgba(255,255,255,.35);
--sidebar-hover-bg-strong: rgba(255,255,255,.42);
--panel-glass: rgba(255,255,255,.56);
--panel-glass-muted: rgba(255, 255, 255, 0.45);
--panel-glass-bright: rgba(255, 255, 255, 0.9);
--panel-glass-strong: rgba(255,255,255,.86);
--line: #d6d2cc; --line: #d6d2cc;
--line-dark: #bdb8b0; --line-dark: #bdb8b0;
--line-subtle: var(--line);
--line-strong: var(--line-dark);
--border: var(--line); --border: var(--line);
--border-line: 1px solid var(--line);
--border-line-dark: 1px solid var(--line-dark);
--border-soft-line: 1px solid var(--border-soft);
--border-muted-line: 1px solid var(--border-muted);
--border-soft: rgba(15, 23, 42, 0.12);
--border-muted: rgba(189, 184, 176, 0.32);
--border-danger-soft: rgba(180, 35, 24, .24);
--text: #48494c; --text: #48494c;
--text-strong: #303135; --text-strong: #303135;
--muted: #8a8781; --muted: #8a8781;
--muted-text: var(--muted); --muted-text: var(--muted);
--text-label: #6b6863;
--text-subtle: #686560;
--text-soft: #666666;
--text-table-heading: #625f5a;
--text-breadcrumb: #615f5c;
--text-hover-strong: #3e3e3f;
--neutral-950: #242424;
--neutral-900: #333333;
--neutral-800: #292929;
--control-disabled-bg: #ebe8e2; --control-disabled-bg: #ebe8e2;
--control-disabled-border: #c2bcb3; --control-disabled-border: #c2bcb3;
--control-disabled-text: #706b63; --control-disabled-text: #706b63;
--control-disabled-placeholder: #969087; --control-disabled-placeholder: #969087;
--control-disabled-label: #7d776f; --control-disabled-label: #7d776f;
--control-bg: #f8f8f7;
--control-border: #c9c5bd;
--control-border-hover: #aaa49a;
--control-border-pressed: #aaa299;
--control-gradient-start: #ffffff;
--control-gradient-end: #f2f1ef;
--control-gradient-end-muted: #f1efeb;
--control-gradient-end-hover: #e9e7e3;
--control-gradient-end-pressed: #e8e5df;
--control-text: #4f4b46;
--control-text-muted: #57534d;
--input-border-focus: #9bb7d3;
--toggle-track-bg: #cfd4da;
--toggle-track-border: #aeb4bb;
--green: #7bbcaf; --green: #7bbcaf;
--amber: #f5c56c; --amber: #f5c56c;
--red: #e06a5f; --red: #e06a5f;
--blue: #7ea6c5; --blue: #7ea6c5;
--primary: #0d6efd;
--primary-soft: rgba(13, 110, 253, .08);
--primary-soft-strong: rgba(13, 110, 253, .16);
--primary-border: rgba(13, 110, 253, .45);
--primary-focus-ring: 0 0 0 3px rgba(13, 110, 253, .18);
--primary-focus-ring-soft: 0 0 0 3px rgba(13,110,253,.2);
--primary-inset-ring: inset 0 0 0 1px rgba(13, 110, 253, .12);
--success-bg: #d8eee8; --success-bg: #d8eee8;
--success-soft: #d6eee9;
--success-border: #5aa99b;
--success-border-soft: #78b7a9;
--success-line: var(--success-border);
--success: #157347;
--success-text: #315f55; --success-text: #315f55;
--success-text-strong: #34796d;
--info-bg: #dce9f3; --info-bg: #dce9f3;
--info-soft: #d8e8f4;
--info-muted-bg: #e8eef5;
--info-muted-border: #c8d6e3;
--info-text: #365c76; --info-text: #365c76;
--info-text-strong: #386a90;
--info-text-muted: #36566f;
--info-text-deep: #294a61;
--warning-bg: #ffe1a3;
--warning-bg-hover: #ffd170;
--warning-soft: #ffedc6;
--warning-muted-bg: #fff1d0;
--warning-border: #d5a64a;
--warning-border-soft: rgba(154, 92, 0, .26);
--warning-text: #593700;
--warning-text-hover: #3e2800;
--warning-text-strong: #a06b00;
--warning: #8a5b00;
--warning-deep: #7c5412;
--danger-bg: #f8d1cc;
--danger-soft: #f6e3df;
--danger-muted-bg: #fff7f5;
--danger-border: #c96b63;
--danger-border-strong: #c94d43;
--danger-border-deep: #b42318;
--danger-focus-ring: 0 0 0 3px rgba(180, 35, 24, .14);
--danger-focus-ring-soft: 0 0 0 3px rgba(201, 107, 99, .13);
--danger-text: #a64840; --danger-text: #a64840;
--danger-text-strong: #b13e35;
--danger-text-deep: #873c35;
--danger-text-tooltip: #7a241c;
--on-accent: #ffffff;
--on-dark: #ffffff;
--code-bg: #292929;
--code-text: #f1f1f1;
--status-neutral-bg: #e7e4df;
--step-number-bg: #d8d6d2;
--loading-line: #8c8881;
--brand-mark-gradient: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0);
--focus-rgb: 82, 130, 177;
--focus-ring: 0 0 0 3px rgba(82, 130, 177, .14); --focus-ring: 0 0 0 3px rgba(82, 130, 177, .14);
--focus-ring-color: rgba(82, 130, 177, .22);
--focus-ring-strong: 0 0 0 3px rgba(82, 130, 177, .22);
--focus-outline: 2px solid color-mix(in srgb, var(--blue) 55%, var(--white));
--text-table-heading-muted-border: rgba(98, 95, 90, .24);
--text-table-heading-muted: rgba(98, 95, 90, .68);
--overlay-backdrop: rgba(37, 40, 42, 0.38);
--dialog-backdrop: rgba(15, 23, 42, 0.36);
--hover-tint: rgba(0,0,0,.08);
--hover-tint-soft: rgba(0, 0, 0, .06);
--hover-tint-strong: rgba(0,0,0,.15);
--titlebar-hover-bg: rgba(0,0,0,.055);
--accent-hover-bg: rgba(239, 107, 58, .10);
--accent-auth-glow: rgba(239, 107, 58, .13);
--accent-ring-soft: 0 0 0 2px rgba(239, 107, 58, .16);
--blue-auth-glow: rgba(126, 166, 197, .15);
--transparent-surface: rgba(255, 255, 255, 0.00);
--loading-envelope-bg: rgba(255,255,255,.7);
--black-border-soft: rgba(0,0,0,.2);
--black-border-muted: rgba(0,0,0,.18);
--shadow-color-rgb: 0, 0, 0;
--shadow-warm-rgb: 45, 43, 40;
--shadow-slate-rgb: 15, 23, 42;
--shadow-highlight-rgb: 255, 255, 255;
--shadow: 0 1px 2px rgba(0,0,0,.15), 0 10px 30px rgba(0,0,0,.04); --shadow: 0 1px 2px rgba(0,0,0,.15), 0 10px 30px rgba(0,0,0,.04);
--shadow-menu: 0 16px 40px rgba(0,0,0,.18); --shadow-menu: 0 16px 40px rgba(0,0,0,.18);
--shadow-popover: 0 3px 8px -3px rgba(0,0,0,.16); --shadow-popover: 0 3px 8px -3px rgba(0,0,0,.16);
--shadow-card: 0 2px 10px rgba(0,0,0,.06);
--shadow-soft: 0 10px 28px rgba(15, 23, 42, 0.12);
--shadow-strong: 0 24px 64px rgba(15, 23, 42, 0.25);
--shadow-modal: 0 24px 80px rgba(0, 0, 0, 0.26);
--shadow-floating: 0 16px 38px rgba(15, 23, 42, 0.2);
--shadow-dismissible: 0 12px 24px rgba(15, 23, 42, 0.08);
--shadow-header: 0 8px 18px rgba(65, 60, 52, .08);
--shadow-chrome: 0 0 10px 0 darkgrey;
--shadow-control: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
--shadow-control-strong: inset 0 1px 0 rgba(255,255,255,.85), 0 1px 1px rgba(0,0,0,.05);
--shadow-control-hover: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
--shadow-control-inset: inset 0 1px 2px rgba(0,0,0,.04);
--shadow-control-inset-strong: inset 0 1px 2px rgba(0,0,0,.12);
--shadow-control-surface: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 2px rgba(0,0,0,.035);
--shadow-inset-highlight: inset 0 1px 0 rgba(255,255,255,.45);
--shadow-inset-highlight-strong: inset 0 1px 0 rgba(255,255,255,.75);
--shadow-inset-highlight-muted: inset 0 1px 0 rgba(255,255,255,.32);
--shadow-sticky-start: 8px 0 14px -14px rgba(45, 43, 40, .6);
--shadow-sticky-end: -8px 0 14px -14px rgba(45, 43, 40, .6);
--shadow-data-header: 0 12px 16px -18px rgba(45, 43, 40, .8);
--shadow-rail: inset -1px 0 rgba(0,0,0,.35);
--shadow-xs: 0 1px 2px rgba(0,0,0,.05);
--shadow-thumb: 0 1px 2px rgba(0,0,0,.22);
--shadow-campaign-card: 0 14px 18px -22px rgba(45, 43, 40, .75);
--shadow-campaign-soft: 0 1px 2px rgba(38, 35, 30, .04);
--shadow-campaign-small: 0 4px 12px rgba(0,0,0,.09);
--shadow-footer: 0 -8px 24px rgba(15, 23, 42, .06);
--radius: 8px; --radius: 8px;
--radius-xs: 5px;
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: var(--radius); --radius-md: var(--radius);
--radius-lg: 12px; --radius-lg: 12px;
--radius-pill: 999px; --radius-pill: 999px;
--theme-preview-light-bg: #e9e7e4;
--theme-preview-light-bar: #d4d0ca;
--theme-preview-light-surface: #ffffff;
--theme-preview-light-line: #d6d2cc;
--theme-preview-light-text: #303135;
--theme-preview-light-muted: #8a8781;
--theme-preview-dark-bg: #202120;
--theme-preview-dark-bar: #34342f;
--theme-preview-dark-surface: #262724;
--theme-preview-dark-line: #454740;
--theme-preview-dark-text: #f6f4ed;
--theme-preview-dark-muted: #aaa79d;
--calendar-event-bg-default: #edf8f5;
--calendar-event-border-default: #b9d7d0;
--calendar-event-text: #21453e;
--calendar-event-muted-text: #4d6764;
--calendar-grid-bg: #f8f6f2;
--calendar-muted-bg: #f1efeb;
--calendar-today-bg: #e3f3ef;
--calendar-selected-bg: #e0f0ea;
--calendar-off-hours-rgb: 241, 239, 235;
--calendar-day-shade: rgba(241, 239, 235, .74);
--calendar-success-border: #4f9489;
--calendar-danger-bg: #fff2f0;
--calendar-danger-border: #f0b8b2;
--calendar-overlay: rgba(33, 69, 62, .46);
--calendar-switch-bg: #d8d3cb;
--calendar-focus-inset: inset 0 0 0 2px rgba(90, 169, 155, .55);
--calendar-focus-outline: 2px solid rgba(90, 169, 155, .35);
--review-flow-purple: #9b86c7;
--campaign-accent: #5d776c;
--campaign-card-bg: rgba(247, 246, 244, .96);
--campaign-card-bg-soft: rgba(247, 246, 244, .46);
--campaign-panel-border: rgba(189, 184, 176, .8);
--campaign-panel-bg: rgba(255, 255, 255, .91);
--campaign-stage-shadow: 0 4px 12px rgba(48, 49, 53, .10);
--campaign-stage-shadow-color: rgba(48, 49, 53, .10);
--campaign-panel-shadow: 0 8px 24px rgba(48, 49, 53, .10);
--campaign-panel-shadow-color: rgba(48, 49, 53, .14);
--campaign-panel-shadow-strong: 0 12px 32px rgba(48, 49, 53, .14);
--campaign-card-shadow: 0 14px 18px -22px rgba(45, 43, 40, .75);
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
color-scheme: dark;
--rail-bg: #171819; --rail-bg: #171819;
--rail-bg-active: #0f1011; --rail-bg-active: #0f1011;
--rail-text: #dfddd7;
--rail-text-muted: #aaa79d;
--bg: #202120; --bg: #202120;
--bar: #34342f; --bar: #34342f;
--panel: #2b2c2a; --panel: #2b2c2a;
@@ -60,21 +263,120 @@
--panel-header: #30312e; --panel-header: #30312e;
--surface: #262724; --surface: #262724;
--surface-subtle: #2e302c; --surface-subtle: #2e302c;
--surface-muted: #2e302c;
--surface-strong: #34342f;
--surface-raised: #242523;
--titlebar-bg: #242523;
--sidebar-bg: #2b2c2a;
--sidebar-border: #454740;
--sidebar-hover-bg: rgba(255,255,255,.08);
--sidebar-hover-bg-strong: rgba(255,255,255,.12);
--panel-glass: rgba(36,37,35,.72);
--panel-glass-muted: rgba(255,255,255,.08);
--panel-glass-bright: rgba(38,39,36,.94);
--panel-glass-strong: rgba(38,39,36,.92);
--line: #454740; --line: #454740;
--line-dark: #5b5d54; --line-dark: #5b5d54;
--border: var(--line); --border: var(--line);
--border-soft: rgba(255,255,255,.10);
--border-muted: rgba(91, 93, 84, .55);
--text: #dfddd7; --text: #dfddd7;
--text-strong: #f6f4ed; --text-strong: #f6f4ed;
--muted: #aaa79d; --muted: #aaa79d;
--muted-text: var(--muted); --muted-text: var(--muted);
--text-label: #c9c5bd;
--text-subtle: #c9c5bd;
--text-soft: #aaa79d;
--text-table-heading: #dfddd7;
--text-breadcrumb: #dfddd7;
--text-hover-strong: #f6f4ed;
--control-disabled-bg: #33352f; --control-disabled-bg: #33352f;
--control-disabled-border: #5f6058; --control-disabled-border: #5f6058;
--control-disabled-text: #b8b4aa; --control-disabled-text: #b8b4aa;
--control-disabled-placeholder: #918d83; --control-disabled-placeholder: #918d83;
--control-disabled-label: #aaa69b; --control-disabled-label: #aaa69b;
--control-bg: #30312e;
--control-border: #5b5d54;
--control-border-hover: #74776e;
--control-border-pressed: #74776e;
--control-gradient-start: #34342f;
--control-gradient-end: #2b2c2a;
--control-gradient-end-muted: #2b2c2a;
--control-gradient-end-hover: #3b3c36;
--control-gradient-end-pressed: #242523;
--control-text: #f6f4ed;
--control-text-muted: #dfddd7;
--input-border-focus: #7ea6c5;
--toggle-track-bg: #454740;
--toggle-track-border: #5b5d54;
--status-neutral-bg: #34342f;
--step-number-bg: #454740;
--loading-line: #aaa79d;
--code-bg: #171819;
--code-text: #f6f4ed;
--overlay-backdrop: rgba(15, 16, 17, 0.58);
--dialog-backdrop: rgba(15, 16, 17, 0.62);
--hover-tint: rgba(255,255,255,.08);
--hover-tint-soft: rgba(255,255,255,.06);
--hover-tint-strong: rgba(255,255,255,.14);
--titlebar-hover-bg: rgba(255,255,255,.08);
--accent-hover-bg: rgba(239, 107, 58, .16);
--accent-auth-glow: rgba(239, 107, 58, .18);
--blue-auth-glow: rgba(126, 166, 197, .18);
--transparent-surface: rgba(38, 39, 36, 0);
--warning-bg: #7c5412;
--warning-bg-hover: #8a5b00;
--warning-soft: #5a431f;
--warning-muted-bg: #4b3b22;
--warning-text: #ffe1a3;
--warning-text-hover: #ffedc6;
--warning-text-strong: #ffe1a3;
--danger-bg: #5f2e2a;
--danger-soft: #4f2d2a;
--danger-muted-bg: #3c2523;
--danger-text: #f0b8b2;
--danger-text-strong: #f8d1cc;
--danger-text-deep: #f8d1cc;
--danger-text-tooltip: #f8d1cc;
--success-bg: #24473f;
--success-soft: #24473f;
--success-text: #d8eee8;
--success-text-strong: #d6eee9;
--info-bg: #243d4e;
--info-soft: #243d4e;
--info-text: #dce9f3;
--info-text-strong: #d8e8f4;
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 10px 30px rgba(0,0,0,.18); --shadow: 0 1px 2px rgba(0,0,0,.35), 0 10px 30px rgba(0,0,0,.18);
--shadow-menu: 0 18px 44px rgba(0,0,0,.42); --shadow-menu: 0 18px 44px rgba(0,0,0,.42);
--shadow-popover: 0 8px 22px rgba(0,0,0,.36); --shadow-popover: 0 8px 22px rgba(0,0,0,.36);
--shadow-card: 0 2px 10px rgba(0,0,0,.22);
--shadow-soft: 0 10px 28px rgba(0,0,0,.28);
--shadow-strong: 0 24px 64px rgba(0,0,0,.46);
--shadow-modal: 0 24px 80px rgba(0,0,0,.52);
--shadow-floating: 0 18px 44px rgba(0,0,0,.42);
--shadow-dismissible: 0 12px 24px rgba(0,0,0,.28);
--shadow-header: 0 8px 18px rgba(0,0,0,.24);
--shadow-chrome: 0 0 10px 0 rgba(0,0,0,.42);
--shadow-data-header: 0 12px 16px -18px rgba(0,0,0,.9);
--shadow-rail: inset -1px 0 rgba(0,0,0,.52);
--shadow-xs: 0 1px 2px rgba(0,0,0,.28);
--shadow-thumb: 0 1px 2px rgba(0,0,0,.42);
--shadow-campaign-card: 0 14px 18px -22px rgba(0,0,0,.85);
--shadow-campaign-soft: 0 1px 2px rgba(0,0,0,.24);
--shadow-campaign-small: 0 4px 12px rgba(0,0,0,.32);
--shadow-footer: 0 -8px 24px rgba(0,0,0,.28);
--calendar-grid-bg: #242523;
--calendar-muted-bg: #34342f;
--calendar-today-bg: #24473f;
--calendar-selected-bg: #24473f;
--calendar-day-shade: rgba(52, 52, 47, .72);
--calendar-danger-bg: #4f2d2a;
--calendar-overlay: rgba(15, 16, 17, .56);
--calendar-switch-bg: #454740;
--campaign-card-bg: rgba(43, 44, 42, .96);
--campaign-card-bg-soft: rgba(43, 44, 42, .58);
--campaign-panel-border: rgba(91, 93, 84, .8);
--campaign-panel-bg: rgba(38, 39, 36, .94);
} }
.ui-reduce-motion *, .ui-reduce-motion *,

View File

@@ -110,6 +110,59 @@ export type AuthInfo = {
available_languages?: Array<{ code: string; label: string; native_label?: string | null }>; available_languages?: Array<{ code: string; label: string; native_label?: string | null }>;
enabled_language_codes?: string[]; enabled_language_codes?: string[];
default_language?: string; default_language?: string;
profile_loaded: boolean;
roles_loaded: boolean;
groups_loaded: boolean;
};
export type AuthUpdate = Partial<Omit<AuthInfo, "user" | "tenant" | "active_tenant" | "tenants">> & {
user?: Partial<AuthUser> | null;
tenant?: AuthTenant | null;
active_tenant?: AuthTenant | null;
tenants?: AuthTenantMembership[] | null;
};
export type AuthSessionInfo = {
authenticated: boolean;
auth_method: "session" | "api_key";
user: Pick<AuthUser, "id" | "account_id" | "email" | "display_name" | "tenant_display_name" | "is_tenant_admin" | "password_reset_required">;
tenant: AuthTenant;
active_tenant: AuthTenant;
session_id?: string | null;
api_key_id?: string | null;
expires_at?: string | null;
};
export type AuthShellInfo = {
user: AuthUser;
tenant: AuthTenant;
active_tenant: AuthTenant;
tenants?: AuthTenantMembership[];
scopes: string[];
principal?: PrincipalContext | null;
profile_loaded?: boolean;
roles_loaded?: boolean;
groups_loaded?: boolean;
};
export type AuthProfileInfo = AuthUpdate & {
user: AuthUser;
tenant: AuthTenant;
active_tenant: AuthTenant;
available_languages?: Array<{ code: string; label: string; native_label?: string | null }>;
enabled_language_codes?: string[];
default_language?: string;
profile_loaded: true;
};
export type AuthRolesInfo = {
roles: AuthRole[];
roles_loaded: true;
};
export type AuthGroupsInfo = {
groups: AuthGroup[];
groups_loaded: true;
}; };
export type LoginResponse = AuthInfo & { export type LoginResponse = AuthInfo & {
@@ -189,7 +242,7 @@ export type PlatformNavItem = {
export type PlatformRouteContext = { export type PlatformRouteContext = {
settings: ApiSettings; settings: ApiSettings;
auth: AuthInfo; auth: AuthInfo;
onAuthChange?: (auth: AuthInfo | null, accessToken?: string) => void; onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
}; };
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string; export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
@@ -214,6 +267,29 @@ export type AdminSectionsUiCapability = {
sections: AdminSectionContribution[]; sections: AdminSectionContribution[];
}; };
export type SettingsSectionGroup = "account" | "ui" | string;
export type SettingsSectionRenderContext = PlatformRouteContext & {
activeSection: string;
availableSections: ReadonlySet<string>;
selectSection: (section: string) => void;
};
export type SettingsSectionContribution = {
id: string;
label: string;
group?: SettingsSectionGroup;
groupTitle?: string;
order?: number;
anyOf?: string[];
allOf?: string[];
render: (context: SettingsSectionRenderContext) => ReactNode;
};
export type SettingsSectionsUiCapability = {
sections: SettingsSectionContribution[];
};
export type PlatformRouteContribution = { export type PlatformRouteContribution = {
path: string; path: string;
anyOf?: string[]; anyOf?: string[];
@@ -378,6 +454,7 @@ export type MailServerProfile = {
export type MailCredentialPolicy = { export type MailCredentialPolicy = {
inherit?: boolean | null; inherit?: boolean | null;
allow_override?: boolean | null;
}; };
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains"; export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
@@ -391,6 +468,7 @@ export type MailProfilePolicy = {
imap_credentials?: MailCredentialPolicy | null; imap_credentials?: MailCredentialPolicy | null;
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null; whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
blacklist?: Partial<Record<MailProfilePatternKey, string[]>> | null; blacklist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
allow_lower_level_limits?: Partial<Record<string, boolean>> | null;
}; };
export type MailPolicyValidationInput = { export type MailPolicyValidationInput = {

View File

@@ -13,9 +13,12 @@ function assertDeepEqual(actual: unknown, expected: unknown, message = "values s
} }
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import CredentialPanel from "../src/components/CredentialPanel";
import PasswordField from "../src/components/PasswordField"; import PasswordField from "../src/components/PasswordField";
import MessageDisplayPanel from "../src/components/MessageDisplayPanel"; import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
import MailServerSettingsPanel from "../src/components/mail/MailServerSettingsPanel"; import MailServerSettingsPanel from "../src/components/mail/MailServerSettingsPanel";
import EmailAddressInput from "../src/components/email/EmailAddressInput";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
function noop() {} function noop() {}
@@ -31,6 +34,35 @@ const typedPassword = renderToStaticMarkup(
assert(typedPassword.includes('aria-label="i18n:govoplan-core.show_password.044b852f"'), "typed password shows reveal button"); assert(typedPassword.includes('aria-label="i18n:govoplan-core.show_password.044b852f"'), "typed password shows reveal button");
assert(typedPassword.includes("password-field-toggle"), "typed password gets toggle class"); assert(typedPassword.includes("password-field-toggle"), "typed password gets toggle class");
const translatedAddressInput = renderToStaticMarkup(
<PlatformLanguageProvider>
<EmailAddressInput
value={[]}
onChange={noop}
addLabel="i18n:govoplan-core.add_address.a71075c4"
emptyText="i18n:govoplan-core.no_address_added_yet.809c4247"
suggestions={[{ name: "Ada Lovelace", email: "ada@example.local" }]}
/>
</PlatformLanguageProvider>
);
assert(translatedAddressInput.includes("No address added yet"), "email input empty text is translated");
assert(!translatedAddressInput.includes("i18n:govoplan-core.no_address_added_yet.809c4247"), "email input does not leak empty-text i18n key");
assert(translatedAddressInput.includes('aria-label="Type a name and email address, then press Enter"'), "email input aria label is translated");
const savedCredentialPanel = renderToStaticMarkup(
<CredentialPanel
values={{ username: "sender", password: "" }}
onChange={noop}
savedPassword
savedPasswordPlaceholder="Saved credential"
heading="Credentials"
/>
);
assert(savedCredentialPanel.includes("Credentials"), "credential panel renders heading");
assert(savedCredentialPanel.includes('value="sender"'), "credential panel renders username");
assert(savedCredentialPanel.includes('placeholder="Saved credential"'), "credential panel renders saved password placeholder");
assert(!savedCredentialPanel.includes("password-field-toggle"), "saved empty credential does not show reveal button");
const settingsPanel = renderToStaticMarkup( const settingsPanel = renderToStaticMarkup(
<MailServerSettingsPanel <MailServerSettingsPanel
smtp={{ host: "smtp.example.org", port: 587, username: "sender", password: "", security: "starttls", timeout_seconds: 30 }} smtp={{ host: "smtp.example.org", port: 587, username: "sender", password: "", security: "starttls", timeout_seconds: 30 }}
@@ -142,6 +174,39 @@ const disabledAppendFolderPanel = renderToStaticMarkup(
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is hidden when append target is disabled"); assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is hidden when append target is disabled");
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.use_detected_folder.5ec4965c"), "detected folder action is hidden when append target is disabled"); assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.use_detected_folder.5ec4965c"), "detected folder action is hidden when append target is disabled");
const smtpServerOnlyPanel = renderToStaticMarkup(
<MailServerSettingsPanel
visibleSections={["smtp"]}
mode="server"
smtp={{ host: "smtp.example.org", port: 587, username: "sender", password: "", security: "starttls", timeout_seconds: 30 }}
imap={{ host: "imap.example.org", port: 993, username: "reader", password: "", security: "tls", sent_folder: "auto", timeout_seconds: 30 }}
onSmtpChange={noop}
onImapChange={noop}
/>
);
assert(smtpServerOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused server panel renders server fields");
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "focused server panel hides credential fields");
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.mail_server_settings_sections.40a163b7"), "single focused section hides section switcher");
assert(!smtpServerOnlyPanel.includes("imap.example.org"), "focused SMTP server panel does not render IMAP fields");
const imapCredentialsOnlyPanel = renderToStaticMarkup(
<MailServerSettingsPanel
initialSection="imap"
visibleSections={["imap"]}
mode="credentials"
smtp={{ host: "smtp.example.org", port: 587, username: "sender", password: "", security: "starttls", timeout_seconds: 30 }}
imap={{ host: "imap.example.org", port: 993, username: "reader", password: "", security: "tls", sent_folder: "auto", timeout_seconds: 30 }}
onSmtpChange={noop}
onImapChange={noop}
imapPasswordSaved
imapSavedPasswordPlaceholder="Saved IMAP password"
/>
);
assert(imapCredentialsOnlyPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "focused credentials panel renders credential fields");
assert(!imapCredentialsOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused credentials panel hides server fields");
assert(imapCredentialsOnlyPanel.includes('placeholder="Saved IMAP password"'), "focused credentials panel preserves saved password UX");
assert(!imapCredentialsOnlyPanel.includes("smtp.example.org"), "focused IMAP credentials panel does not render SMTP fields");
const defaultPortSettingsPanel = renderToStaticMarkup( const defaultPortSettingsPanel = renderToStaticMarkup(
<MailServerSettingsPanel <MailServerSettingsPanel
smtp={{ host: "smtp.example.org", security: "starttls" }} smtp={{ host: "smtp.example.org", security: "starttls" }}

View File

@@ -19,6 +19,8 @@
}, },
"include": [ "include": [
"tests/mail-components.test.tsx", "tests/mail-components.test.tsx",
"src/components/CredentialPanel.tsx",
"src/components/email/EmailAddressInput.tsx",
"src/components/PasswordField.tsx", "src/components/PasswordField.tsx",
"src/components/MessageDisplayPanel.tsx", "src/components/MessageDisplayPanel.tsx",
"src/components/mail/MailServerSettingsPanel.tsx", "src/components/mail/MailServerSettingsPanel.tsx",

View File

@@ -13,6 +13,7 @@ const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`;
const defaultWebModulePackages = [ const defaultWebModulePackages = [
"@govoplan/access-webui", "@govoplan/access-webui",
"@govoplan/admin-webui", "@govoplan/admin-webui",
"@govoplan/addresses-webui",
"@govoplan/audit-webui", "@govoplan/audit-webui",
"@govoplan/calendar-webui", "@govoplan/calendar-webui",
"@govoplan/campaign-webui", "@govoplan/campaign-webui",
@@ -21,6 +22,7 @@ const defaultWebModulePackages = [
"@govoplan/files-webui", "@govoplan/files-webui",
"@govoplan/idm-webui", "@govoplan/idm-webui",
"@govoplan/mail-webui", "@govoplan/mail-webui",
"@govoplan/notifications-webui",
"@govoplan/organizations-webui", "@govoplan/organizations-webui",
"@govoplan/ops-webui", "@govoplan/ops-webui",
"@govoplan/policy-webui", "@govoplan/policy-webui",
@@ -95,6 +97,7 @@ export default defineConfig({
fileURLToPath(new URL('.', import.meta.url)), fileURLToPath(new URL('.', import.meta.url)),
fileURLToPath(new URL('../../govoplan-access/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-access/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-admin/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-admin/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-addresses/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-audit/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-audit/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-calendar/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-calendar/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)),
@@ -102,10 +105,12 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-idm/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-idm/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-notifications/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)) fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url))
] ]
}, },
proxy: { proxy: {