intermittent commit
This commit is contained in:
@@ -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
|
||||
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
|
||||
|
||||
Policy UIs must:
|
||||
|
||||
@@ -126,7 +126,6 @@ are not listed in `requirements-release.txt` or `webui/package.release.json`.
|
||||
|
||||
Current tag-only module repositories:
|
||||
|
||||
- `govoplan-addresses`
|
||||
- `govoplan-appointments`
|
||||
- `govoplan-cases`
|
||||
- `govoplan-connectors`
|
||||
|
||||
@@ -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-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-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
|
||||
|
||||
@@ -138,6 +139,23 @@ adaptive form, not force a linear wizard.
|
||||
- Wizard shells remain available for assisted setup, first-run guidance,
|
||||
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
|
||||
|
||||
| Phase | Scope | Output |
|
||||
|
||||
@@ -45,7 +45,7 @@ Remediation applied on 2026-07-09:
|
||||
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
|
||||
`pyzipper==0.4.0`
|
||||
- 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
|
||||
|
||||
Post-remediation result:
|
||||
|
||||
@@ -94,6 +94,28 @@ class UserInfo(BaseModel):
|
||||
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):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -143,6 +165,40 @@ class PrincipalContextInfo(BaseModel):
|
||||
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):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
@@ -159,6 +215,9 @@ class LoginResponse(BaseModel):
|
||||
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
|
||||
roles_loaded: bool = True
|
||||
groups_loaded: bool = True
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
@@ -174,3 +233,6 @@ class MeResponse(BaseModel):
|
||||
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
|
||||
roles_loaded: bool = True
|
||||
groups_loaded: bool = True
|
||||
|
||||
@@ -5,6 +5,8 @@ from celery import Celery
|
||||
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.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.settings import settings
|
||||
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)
|
||||
|
||||
celery = Celery(
|
||||
"multimailer",
|
||||
"govoplan",
|
||||
broker=settings.redis_url,
|
||||
backend=settings.redis_url,
|
||||
)
|
||||
@@ -21,8 +23,10 @@ celery = Celery(
|
||||
celery.conf.update(
|
||||
task_default_queue="default",
|
||||
task_routes={
|
||||
"multimailer.send_email": {"queue": "send_email"},
|
||||
"multimailer.append_sent": {"queue": "append_sent"},
|
||||
"govoplan.campaigns.send_email": {"queue": "send_email"},
|
||||
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
|
||||
"govoplan.notifications.deliver": {"queue": "notifications"},
|
||||
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
||||
},
|
||||
worker_prefetch_multiplier=1,
|
||||
task_acks_late=True,
|
||||
@@ -30,12 +34,12 @@ celery.conf.update(
|
||||
)
|
||||
|
||||
|
||||
@celery.task(name="multimailer.ping")
|
||||
@celery.task(name="govoplan.ping")
|
||||
def ping():
|
||||
return "pong"
|
||||
|
||||
|
||||
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
|
||||
def _platform_registry() -> PlatformRegistry:
|
||||
raw_enabled_modules = load_startup_enabled_modules(settings.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)
|
||||
@@ -44,13 +48,26 @@ def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
|
||||
context = ModuleContext(registry=registry, settings=settings)
|
||||
configure_runtime(context)
|
||||
registry.configure_capability_context(context)
|
||||
return registry
|
||||
|
||||
|
||||
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
|
||||
registry = _platform_registry()
|
||||
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
|
||||
if not isinstance(capability, CampaignDeliveryTaskProvider):
|
||||
raise RuntimeError("Campaign delivery task capability is invalid")
|
||||
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):
|
||||
"""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))
|
||||
|
||||
|
||||
@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):
|
||||
"""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:
|
||||
raise self.retry(exc=exc, countdown=300)
|
||||
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))
|
||||
|
||||
@@ -5,7 +5,6 @@ import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from govoplan_core.core.module_installer import (
|
||||
ModuleInstallerError,
|
||||
@@ -27,6 +26,13 @@ from govoplan_core.core.module_installer import (
|
||||
update_module_installer_request,
|
||||
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_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
||||
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
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
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("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
|
||||
@@ -95,202 +101,295 @@ def main() -> int:
|
||||
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-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)
|
||||
try:
|
||||
if args.issue_license:
|
||||
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.")
|
||||
if not args.license_signing_key_id or not args.license_signing_private_key:
|
||||
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
|
||||
if not [item for item in args.license_feature if item.strip()]:
|
||||
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
|
||||
try:
|
||||
path = issue_module_license(
|
||||
path=args.issue_license,
|
||||
license_id=args.license_id,
|
||||
subject=args.license_subject,
|
||||
features=args.license_feature,
|
||||
valid_from=args.license_valid_from,
|
||||
valid_until=args.license_valid_until,
|
||||
signing_key_id=args.license_signing_key_id,
|
||||
signing_private_key_path=args.license_signing_private_key,
|
||||
issuer=args.license_issuer,
|
||||
notes=args.license_notes,
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ModuleInstallerError(str(exc)) from exc
|
||||
_print_result(
|
||||
{
|
||||
"issued": True,
|
||||
"path": str(path),
|
||||
"license_id": args.license_id,
|
||||
"subject": args.license_subject,
|
||||
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
|
||||
"valid_until": args.license_valid_until,
|
||||
"key_id": args.license_signing_key_id,
|
||||
},
|
||||
output_format=args.format,
|
||||
)
|
||||
return 0
|
||||
if args.validate_license is not None:
|
||||
path = Path(args.validate_license).expanduser() if args.validate_license else None
|
||||
result = module_license_diagnostics(
|
||||
path,
|
||||
require_trusted=args.require_trusted_license,
|
||||
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
|
||||
required_features=args.license_required_feature,
|
||||
)
|
||||
_print_result(result, output_format=args.format)
|
||||
return 0 if result.get("valid") and not result.get("missing_features") else 1
|
||||
if args.sign_package_catalog:
|
||||
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.")
|
||||
path = sign_module_package_catalog(
|
||||
path=args.sign_package_catalog,
|
||||
key_id=args.catalog_signing_key_id,
|
||||
private_key_path=args.catalog_signing_private_key,
|
||||
output_path=args.catalog_output,
|
||||
)
|
||||
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
|
||||
return 0
|
||||
if args.validate_package_catalog is not None:
|
||||
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
|
||||
result = validate_module_package_catalog(
|
||||
path,
|
||||
require_trusted=args.require_signed_catalog,
|
||||
approved_channels=tuple(args.approved_catalog_channel),
|
||||
trusted_keys=_trusted_keys_from_args(args.catalog_trusted_key, flag_name="--catalog-trusted-key"),
|
||||
)
|
||||
_print_result(result, output_format=args.format)
|
||||
return 0 if result.get("valid") else 1
|
||||
if args.daemon_status:
|
||||
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
|
||||
return 0
|
||||
if args.daemon or args.daemon_once:
|
||||
return _run_daemon(args=args, runtime_dir=runtime_dir)
|
||||
if args.list_requests:
|
||||
_print_result({
|
||||
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
|
||||
"daemon": module_installer_daemon_status(runtime_dir=runtime_dir),
|
||||
}, output_format=args.format)
|
||||
return 0
|
||||
if args.show_request:
|
||||
_print_result(read_module_installer_request(runtime_dir=runtime_dir, request_id=args.show_request), output_format=args.format)
|
||||
return 0
|
||||
if args.cancel_request:
|
||||
_print_result(cancel_module_installer_request(runtime_dir=runtime_dir, request_id=args.cancel_request, cancelled_by="cli"), output_format=args.format)
|
||||
return 0
|
||||
if args.retry_request:
|
||||
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
|
||||
return 0
|
||||
if args.enqueue_supervised:
|
||||
request = queue_module_installer_request(
|
||||
runtime_dir=runtime_dir,
|
||||
requested_by="cli",
|
||||
options=_request_options_from_args(args),
|
||||
)
|
||||
_print_result(request, output_format=args.format)
|
||||
return 0
|
||||
if args.list_runs:
|
||||
_print_result({
|
||||
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
|
||||
"lock": module_installer_lock_status(runtime_dir=runtime_dir),
|
||||
}, output_format=args.format)
|
||||
return 0
|
||||
if args.show_run:
|
||||
_print_result(read_module_installer_run(runtime_dir=runtime_dir, run_id=args.show_run), output_format=args.format)
|
||||
return 0
|
||||
if args.lock_status:
|
||||
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
|
||||
return 0
|
||||
if args.rollback:
|
||||
result = rollback_module_install_run(
|
||||
run_id=args.rollback,
|
||||
runtime_dir=runtime_dir,
|
||||
npm_bin=args.npm_bin,
|
||||
build_webui=args.build_webui,
|
||||
database_restore_command=args.database_restore_command,
|
||||
database_url=str(args.database_url),
|
||||
)
|
||||
_print_result(result.as_dict(), output_format=args.format)
|
||||
return 0 if result.return_code == 0 else 1
|
||||
|
||||
configure_database(str(args.database_url))
|
||||
available = available_module_manifests(ignore_load_errors=True)
|
||||
with get_database().session() as session:
|
||||
configured = configured_enabled_modules(settings.enabled_modules)
|
||||
desired = saved_desired_enabled_modules(session, configured)
|
||||
plan = saved_module_install_plan(session)
|
||||
if args.supervise:
|
||||
result = supervise_module_install_plan(
|
||||
session=session,
|
||||
plan=plan,
|
||||
available=available,
|
||||
current_enabled=desired,
|
||||
desired_enabled=desired,
|
||||
database_url=str(args.database_url),
|
||||
runtime_dir=runtime_dir,
|
||||
webui_root=args.webui_root,
|
||||
npm_bin=args.npm_bin,
|
||||
build_webui=args.build_webui,
|
||||
migrate_database=args.migrate,
|
||||
database_backup_command=args.database_backup_command,
|
||||
database_restore_command=args.database_restore_command,
|
||||
database_restore_check_command=args.database_restore_check_command,
|
||||
activate_installed_modules=not args.no_activate_installed_modules,
|
||||
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
|
||||
restart_command=args.restart_command,
|
||||
health_url=args.health_url,
|
||||
health_timeout_seconds=args.health_timeout_seconds,
|
||||
health_interval_seconds=args.health_interval_seconds,
|
||||
)
|
||||
_print_result(result.as_dict(), output_format=args.format)
|
||||
return 0 if result.return_code == 0 else 1
|
||||
if args.apply or args.dry_run:
|
||||
result = run_module_install_plan(
|
||||
session=session,
|
||||
plan=plan,
|
||||
available=available,
|
||||
current_enabled=desired,
|
||||
desired_enabled=desired,
|
||||
database_url=str(args.database_url),
|
||||
runtime_dir=runtime_dir,
|
||||
webui_root=args.webui_root,
|
||||
npm_bin=args.npm_bin,
|
||||
build_webui=args.build_webui,
|
||||
migrate_database=args.migrate,
|
||||
database_backup_command=args.database_backup_command,
|
||||
database_restore_command=args.database_restore_command,
|
||||
database_restore_check_command=args.database_restore_check_command,
|
||||
activate_installed_modules=not args.no_activate_installed_modules,
|
||||
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
_print_result(result.as_dict(), output_format=args.format)
|
||||
return 0 if result.return_code == 0 else 1
|
||||
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
|
||||
maintenance = saved_maintenance_mode(session)
|
||||
preflight = module_install_preflight(
|
||||
plan=plan,
|
||||
available=available,
|
||||
current_enabled=desired,
|
||||
desired_enabled=desired,
|
||||
maintenance_mode=maintenance.enabled,
|
||||
session=session,
|
||||
webui_root=args.webui_root,
|
||||
runtime_dir=runtime_dir,
|
||||
)
|
||||
_print_preflight(preflight.as_dict(), output_format=args.format)
|
||||
return 0 if preflight.allowed else 1
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
|
||||
if not [item for item in args.license_feature if item.strip()]:
|
||||
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
|
||||
try:
|
||||
path = issue_module_license(
|
||||
path=args.issue_license,
|
||||
license_id=args.license_id,
|
||||
subject=args.license_subject,
|
||||
features=args.license_feature,
|
||||
valid_from=args.license_valid_from,
|
||||
valid_until=args.license_valid_until,
|
||||
signing_key_id=args.license_signing_key_id,
|
||||
signing_private_key_path=args.license_signing_private_key,
|
||||
issuer=args.license_issuer,
|
||||
notes=args.license_notes,
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ModuleInstallerError(str(exc)) from exc
|
||||
_print_result(
|
||||
{
|
||||
"issued": True,
|
||||
"path": str(path),
|
||||
"license_id": args.license_id,
|
||||
"subject": args.license_subject,
|
||||
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
|
||||
"valid_until": args.license_valid_until,
|
||||
"key_id": args.license_signing_key_id,
|
||||
},
|
||||
output_format=args.format,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _validate_license_command(args: argparse.Namespace) -> int:
|
||||
path = Path(args.validate_license).expanduser() if args.validate_license else None
|
||||
result = module_license_diagnostics(
|
||||
path,
|
||||
require_trusted=args.require_trusted_license,
|
||||
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
|
||||
required_features=args.license_required_feature,
|
||||
)
|
||||
_print_result(result, output_format=args.format)
|
||||
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:
|
||||
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:
|
||||
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
|
||||
path = sign_module_package_catalog(
|
||||
path=args.sign_package_catalog,
|
||||
key_id=args.catalog_signing_key_id,
|
||||
private_key_path=args.catalog_signing_private_key,
|
||||
output_path=args.catalog_output,
|
||||
)
|
||||
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
|
||||
return 0
|
||||
|
||||
|
||||
def _validate_package_catalog_command(args: argparse.Namespace) -> int:
|
||||
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
|
||||
result = validate_module_package_catalog(
|
||||
path,
|
||||
require_trusted=args.require_signed_catalog,
|
||||
approved_channels=tuple(args.approved_catalog_channel),
|
||||
trusted_keys=_trusted_keys_from_args(args.catalog_trusted_key, flag_name="--catalog-trusted-key"),
|
||||
)
|
||||
_print_result(result, output_format=args.format)
|
||||
return 0 if result.get("valid") else 1
|
||||
|
||||
|
||||
def _handle_runtime_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||
if args.daemon_status:
|
||||
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
|
||||
return 0
|
||||
if args.daemon or args.daemon_once:
|
||||
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:
|
||||
_print_result({
|
||||
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
|
||||
"daemon": module_installer_daemon_status(runtime_dir=runtime_dir),
|
||||
}, output_format=args.format)
|
||||
return 0
|
||||
if args.show_request:
|
||||
_print_result(read_module_installer_request(runtime_dir=runtime_dir, request_id=args.show_request), output_format=args.format)
|
||||
return 0
|
||||
if args.cancel_request:
|
||||
_print_result(cancel_module_installer_request(runtime_dir=runtime_dir, request_id=args.cancel_request, cancelled_by="cli"), output_format=args.format)
|
||||
return 0
|
||||
if args.retry_request:
|
||||
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
|
||||
return 0
|
||||
if args.enqueue_supervised:
|
||||
request = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="cli", options=_request_options_from_args(args))
|
||||
_print_result(request, output_format=args.format)
|
||||
return 0
|
||||
return None
|
||||
|
||||
|
||||
def _handle_run_history_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||
if args.list_runs:
|
||||
_print_result({
|
||||
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
|
||||
"lock": module_installer_lock_status(runtime_dir=runtime_dir),
|
||||
}, output_format=args.format)
|
||||
return 0
|
||||
if args.show_run:
|
||||
_print_result(read_module_installer_run(runtime_dir=runtime_dir, run_id=args.show_run), output_format=args.format)
|
||||
return 0
|
||||
if args.lock_status:
|
||||
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
|
||||
return 0
|
||||
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(
|
||||
run_id=args.rollback,
|
||||
runtime_dir=runtime_dir,
|
||||
npm_bin=args.npm_bin,
|
||||
build_webui=args.build_webui,
|
||||
database_restore_command=args.database_restore_command,
|
||||
database_url=str(args.database_url),
|
||||
)
|
||||
_print_result(result.as_dict(), output_format=args.format)
|
||||
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))
|
||||
available = available_module_manifests(ignore_load_errors=True)
|
||||
with get_database().session() as session:
|
||||
configured = configured_enabled_modules(settings.enabled_modules)
|
||||
desired = saved_desired_enabled_modules(session, configured)
|
||||
plan = saved_module_install_plan(session)
|
||||
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(
|
||||
session=session,
|
||||
plan=plan,
|
||||
available=available,
|
||||
current_enabled=desired,
|
||||
desired_enabled=desired,
|
||||
database_url=str(args.database_url),
|
||||
runtime_dir=runtime_dir,
|
||||
webui_root=args.webui_root,
|
||||
npm_bin=args.npm_bin,
|
||||
build_webui=args.build_webui,
|
||||
migrate_database=args.migrate,
|
||||
database_backup_command=args.database_backup_command,
|
||||
database_restore_command=args.database_restore_command,
|
||||
database_restore_check_command=args.database_restore_check_command,
|
||||
activate_installed_modules=not args.no_activate_installed_modules,
|
||||
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
|
||||
restart_command=args.restart_command,
|
||||
health_url=args.health_url,
|
||||
health_timeout_seconds=args.health_timeout_seconds,
|
||||
health_interval_seconds=args.health_interval_seconds,
|
||||
)
|
||||
_print_result(result.as_dict(), output_format=args.format)
|
||||
return 0 if result.return_code == 0 else 1
|
||||
|
||||
|
||||
def _apply_install_command(
|
||||
*,
|
||||
args: argparse.Namespace,
|
||||
runtime_dir: Path,
|
||||
session: object,
|
||||
available: object,
|
||||
desired: object,
|
||||
plan: object,
|
||||
) -> int:
|
||||
result = run_module_install_plan(
|
||||
session=session,
|
||||
plan=plan,
|
||||
available=available,
|
||||
current_enabled=desired,
|
||||
desired_enabled=desired,
|
||||
database_url=str(args.database_url),
|
||||
runtime_dir=runtime_dir,
|
||||
webui_root=args.webui_root,
|
||||
npm_bin=args.npm_bin,
|
||||
build_webui=args.build_webui,
|
||||
migrate_database=args.migrate,
|
||||
database_backup_command=args.database_backup_command,
|
||||
database_restore_command=args.database_restore_command,
|
||||
database_restore_check_command=args.database_restore_check_command,
|
||||
activate_installed_modules=not args.no_activate_installed_modules,
|
||||
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
_print_result(result.as_dict(), output_format=args.format)
|
||||
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
|
||||
|
||||
maintenance = saved_maintenance_mode(session)
|
||||
preflight = module_install_preflight(
|
||||
plan=plan,
|
||||
available=available,
|
||||
current_enabled=desired,
|
||||
desired_enabled=desired,
|
||||
maintenance_mode=maintenance.enabled,
|
||||
session=session,
|
||||
webui_root=args.webui_root,
|
||||
runtime_dir=runtime_dir,
|
||||
)
|
||||
_print_preflight(preflight.as_dict(), output_format=args.format)
|
||||
return 0 if preflight.allowed else 1
|
||||
|
||||
|
||||
def _default_webui_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3] / "webui"
|
||||
|
||||
@@ -355,6 +454,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
||||
request_id = str(request["request_id"])
|
||||
options = request.get("options") if isinstance(request.get("options"), dict) else {}
|
||||
configure_database(str(args.database_url))
|
||||
_emit_installer_daemon_notification(request, event_kind="module_installer.request.running")
|
||||
try:
|
||||
available = available_module_manifests(ignore_load_errors=True)
|
||||
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),
|
||||
request_context=_request_context(request),
|
||||
)
|
||||
update_module_installer_request(
|
||||
updated_request = update_module_installer_request(
|
||||
runtime_dir=runtime_dir,
|
||||
request_id=request_id,
|
||||
patch={
|
||||
@@ -393,8 +493,12 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
||||
"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:
|
||||
update_module_installer_request(
|
||||
updated_request = update_module_installer_request(
|
||||
runtime_dir=runtime_dir,
|
||||
request_id=request_id,
|
||||
patch={
|
||||
@@ -403,6 +507,34 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
||||
"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]:
|
||||
@@ -427,6 +559,7 @@ def _request_context(request: dict[str, object]) -> dict[str, object]:
|
||||
context: dict[str, object] = {
|
||||
"request_id": request.get("request_id"),
|
||||
"requested_by": request.get("requested_by"),
|
||||
"tenant_id": request.get("tenant_id"),
|
||||
}
|
||||
if request.get("retry_of"):
|
||||
context["retry_of"] = request["retry_of"]
|
||||
|
||||
@@ -8,8 +8,6 @@ from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from govoplan_core.core.module_package_catalog import (
|
||||
_canonical_catalog_bytes,
|
||||
@@ -23,6 +21,7 @@ from govoplan_core.core.module_package_catalog import (
|
||||
_load_private_key,
|
||||
_parse_trusted_keys,
|
||||
)
|
||||
from govoplan_core.security.http_fetch import fetch_http_text
|
||||
|
||||
|
||||
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||
@@ -31,6 +30,20 @@ DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
||||
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)
|
||||
class ConfigurationModuleRequirement:
|
||||
module_id: str
|
||||
@@ -461,55 +474,148 @@ def validate_configuration_package_catalog(
|
||||
configured = source is not None and _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}")
|
||||
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:
|
||||
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_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()
|
||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
||||
freshness = _catalog_freshness_state(payload)
|
||||
replay = _catalog_replay_state(channel=channel, sequence=sequence)
|
||||
state = _configuration_catalog_validation_state(source, trusted_keys=effective_trusted_keys)
|
||||
read_state = state.read_state
|
||||
except Exception as exc:
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
|
||||
if signature_state.get("fatal"):
|
||||
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"]))
|
||||
if effective_approved_channels and channel not in effective_approved_channels:
|
||||
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)}.")
|
||||
if effective_require_trusted and not signature_state["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."))
|
||||
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 not replay["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(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."))
|
||||
policy_error = _configuration_catalog_policy_error(
|
||||
source,
|
||||
configured=configured,
|
||||
state=state,
|
||||
require_trusted=effective_require_trusted,
|
||||
approved_channels=effective_approved_channels,
|
||||
)
|
||||
if policy_error is not None:
|
||||
return policy_error
|
||||
return _validation_result(
|
||||
source,
|
||||
valid=True,
|
||||
configured=configured,
|
||||
read_state=read_state,
|
||||
packages=packages,
|
||||
read_state=state.read_state,
|
||||
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,
|
||||
sequence=sequence,
|
||||
generated_at=generated_at,
|
||||
not_before=not_before,
|
||||
expires_at=expires_at,
|
||||
signature_state=signature_state,
|
||||
warnings=warnings,
|
||||
generated_at=_catalog_optional_text(payload, "generated_at"),
|
||||
not_before=_catalog_optional_text(payload, "not_before"),
|
||||
expires_at=_catalog_optional_text(payload, "expires_at"),
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
payload = _read_catalog_payload(path)
|
||||
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:
|
||||
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()
|
||||
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 = response.read().decode("utf-8")
|
||||
body = fetch_http_text(url, timeout=15, label="Trusted configuration catalog key URL")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return body
|
||||
except (OSError, urllib.error.URLError):
|
||||
except OSError:
|
||||
if cache_path is not None and cache_path.exists():
|
||||
return cache_path.read_text(encoding="utf-8")
|
||||
raise
|
||||
@@ -714,13 +817,12 @@ def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[obje
|
||||
return {"packages": []}, metadata
|
||||
if isinstance(source, str) and _is_http_url(source):
|
||||
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 = response.read().decode("utf-8")
|
||||
body = fetch_http_text(source, timeout=15, label="Configuration package catalog URL")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return json.loads(body), metadata
|
||||
except (OSError, urllib.error.URLError):
|
||||
except OSError:
|
||||
if cache_path is not None and cache_path.exists():
|
||||
metadata["cache_used"] = True
|
||||
return json.loads(cache_path.read_text(encoding="utf-8")), metadata
|
||||
|
||||
@@ -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, ...] = (
|
||||
ConfigurationFieldSafety(
|
||||
key="module_management.desired_enabled",
|
||||
@@ -339,23 +349,56 @@ def plan_configuration_change(
|
||||
) -> ConfigurationChangeSafetyPlan:
|
||||
field = classify_configuration_field(key)
|
||||
if field is None:
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=key,
|
||||
allowed=False,
|
||||
field=None,
|
||||
blockers=("unknown_configuration_field",),
|
||||
policy_explanation="This setting is not in the configuration safety catalog.",
|
||||
)
|
||||
return _unknown_configuration_plan(key)
|
||||
if not include_env_only and not field.ui_managed:
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=key,
|
||||
allowed=False,
|
||||
field=field,
|
||||
risk=field.risk,
|
||||
secret_handling=field.secret_handling,
|
||||
blockers=("deployment_managed",),
|
||||
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
|
||||
)
|
||||
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(
|
||||
key=key,
|
||||
allowed=False,
|
||||
field=None,
|
||||
blockers=("unknown_configuration_field",),
|
||||
policy_explanation="This setting is not in the configuration safety catalog.",
|
||||
)
|
||||
|
||||
|
||||
def _deployment_managed_configuration_plan(key: str, field: ConfigurationFieldSafety) -> ConfigurationChangeSafetyPlan:
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=key,
|
||||
allowed=False,
|
||||
field=field,
|
||||
risk=field.risk,
|
||||
secret_handling=field.secret_handling,
|
||||
blockers=("deployment_managed",),
|
||||
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] = []
|
||||
warnings: list[str] = []
|
||||
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")
|
||||
if field.rollback_history_required:
|
||||
warnings.append("rollback_history_required")
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=field.key,
|
||||
allowed=not blockers,
|
||||
return _ConfigurationChangeSafetyState(
|
||||
field=field,
|
||||
risk=field.risk,
|
||||
missing_scopes=missing_scopes,
|
||||
dry_run_required=field.dry_run_required,
|
||||
dry_run_satisfied=not field.dry_run_required or dry_run,
|
||||
blockers=tuple(dict.fromkeys(blockers)),
|
||||
warnings=tuple(dict.fromkeys(warnings)),
|
||||
approval_required=approval_required,
|
||||
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_satisfied=not field.maintenance_required or maintenance_mode,
|
||||
rollback_history_required=field.rollback_history_required,
|
||||
secret_handling=field.secret_handling,
|
||||
audit_event=field.audit_event,
|
||||
policy_explanation=_policy_explanation(field),
|
||||
blockers=tuple(dict.fromkeys(blockers)),
|
||||
warnings=tuple(dict.fromkeys(warnings)),
|
||||
blockers=state.blockers,
|
||||
warnings=state.warnings,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,25 @@ class ConfigValidationResult:
|
||||
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"}
|
||||
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
|
||||
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
|
||||
@@ -114,95 +133,130 @@ def validate_runtime_configuration(
|
||||
strict: bool = False,
|
||||
) -> ConfigValidationResult:
|
||||
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"))
|
||||
issues: list[ConfigIssue] = []
|
||||
runtime = _runtime_profile(env, profile=profile)
|
||||
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_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"))
|
||||
if not app_env and 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.")
|
||||
elif app_env.lower() in {"dev", "test", "local"} and 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.")
|
||||
if not app_env and runtime.production_like:
|
||||
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 runtime.production_like:
|
||||
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"))
|
||||
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.")
|
||||
else:
|
||||
backend = _database_backend(database_url)
|
||||
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.")
|
||||
elif backend == "sqlite" and production_like:
|
||||
issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
|
||||
elif backend != "postgresql" and production:
|
||||
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.")
|
||||
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("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
|
||||
return
|
||||
backend = _database_backend(database_url)
|
||||
if backend is None:
|
||||
collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
|
||||
return
|
||||
if backend == "sqlite" and runtime.production_like:
|
||||
collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
|
||||
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")):
|
||||
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"))
|
||||
if not master_key and not 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.")
|
||||
elif master_key:
|
||||
error = _master_key_error(master_key)
|
||||
if error:
|
||||
issue("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():
|
||||
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
|
||||
if not master_key and not runtime.local:
|
||||
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.")
|
||||
return
|
||||
if not master_key:
|
||||
return
|
||||
error = _master_key_error(master_key)
|
||||
if error:
|
||||
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():
|
||||
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"))
|
||||
if not enabled_modules and production_like:
|
||||
issue("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:
|
||||
issue("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:
|
||||
issue("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
|
||||
if not enabled_modules and runtime.production_like:
|
||||
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 runtime.production_like:
|
||||
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 runtime.production_like:
|
||||
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")):
|
||||
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.")
|
||||
def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
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"))
|
||||
if production_like and not cors_origins:
|
||||
issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
|
||||
elif "*" in cors_origins and production_like:
|
||||
issue("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:
|
||||
issue("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
||||
if runtime.production_like and not cors_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 runtime.production_like:
|
||||
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
||||
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
||||
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"
|
||||
if storage_backend == "local":
|
||||
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like:
|
||||
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.")
|
||||
_validate_local_file_storage(env, runtime, collector)
|
||||
elif storage_backend == "s3":
|
||||
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)):
|
||||
issue("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
||||
_validate_s3_file_storage(env, collector)
|
||||
else:
|
||||
issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
|
||||
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"):
|
||||
if not _clean(env.get(key)):
|
||||
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
||||
|
||||
|
||||
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"))
|
||||
if production and catalog_source:
|
||||
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.")
|
||||
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.")
|
||||
|
||||
return ConfigValidationResult(profile=clean_profile, issues=tuple(issues))
|
||||
if not runtime.production or not catalog_source:
|
||||
return
|
||||
if not _clean(env.get("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")):
|
||||
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.")
|
||||
|
||||
|
||||
def _self_hosted_env_template(master_key: str) -> str:
|
||||
@@ -299,8 +353,8 @@ def _master_key_error(value: str) -> str | None:
|
||||
try:
|
||||
Fernet(candidate)
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
except (TypeError, ValueError):
|
||||
raw = None
|
||||
try:
|
||||
raw = base64.b64decode(candidate)
|
||||
except Exception:
|
||||
@@ -309,6 +363,6 @@ def _master_key_error(value: str) -> str | None:
|
||||
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
|
||||
try:
|
||||
Fernet(base64.urlsafe_b64encode(raw))
|
||||
except Exception:
|
||||
except (TypeError, ValueError):
|
||||
return "MASTER_KEY_B64 is not usable as a Fernet key."
|
||||
return None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
125
src/govoplan_core/core/module_installer_notifications.py
Normal file
125
src/govoplan_core/core/module_installer_notifications.py
Normal 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
|
||||
@@ -7,13 +7,11 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
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]:
|
||||
@@ -258,18 +256,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
||||
|
||||
|
||||
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()
|
||||
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 = response.read().decode("utf-8")
|
||||
body = fetch_http_text(url, timeout=15, label="Trusted license key URL")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return body
|
||||
except (OSError, urllib.error.URLError):
|
||||
except OSError:
|
||||
if cache_path is not None and cache_path.exists():
|
||||
return cache_path.read_text(encoding="utf-8")
|
||||
raise
|
||||
|
||||
@@ -73,6 +73,23 @@ class ModuleInstallPlanItem:
|
||||
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)
|
||||
class ModuleInstallPlan:
|
||||
items: tuple[ModuleInstallPlanItem, ...] = ()
|
||||
@@ -291,6 +308,28 @@ def desired_modules_after_package_plan(
|
||||
def normalize_module_install_plan_item(
|
||||
item: Mapping[str, object] | 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):
|
||||
raw = item.as_dict()
|
||||
elif isinstance(item, Mapping):
|
||||
@@ -311,33 +350,12 @@ def normalize_module_install_plan_item(
|
||||
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
|
||||
destroy_data = _clean_bool(raw.get("destroy_data"))
|
||||
notes = _clean_optional_string(raw.get("notes"))
|
||||
|
||||
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(
|
||||
return _NormalizedModuleInstallPlanItem(
|
||||
module_id=module_id,
|
||||
action=action,
|
||||
source=source,
|
||||
catalog=catalog,
|
||||
status=status,
|
||||
python_package=python_package,
|
||||
python_ref=python_ref,
|
||||
webui_package=webui_package,
|
||||
@@ -345,11 +363,41 @@ def normalize_module_install_plan_item(
|
||||
artifact_integrity=artifact_integrity,
|
||||
data_safety_acknowledged=data_safety_acknowledged,
|
||||
destroy_data=destroy_data,
|
||||
status=status,
|
||||
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(
|
||||
requested_enabled: Iterable[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
|
||||
@@ -3,20 +3,20 @@ from __future__ import annotations
|
||||
import base64
|
||||
import binascii
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
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.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_]*)+$")
|
||||
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(
|
||||
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_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):
|
||||
return {
|
||||
"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}
|
||||
return _catalog_error_result(catalog_source, error=f"Module package catalog does not exist: {catalog_source}")
|
||||
try:
|
||||
payload, read_state = _read_catalog_payload_with_metadata(catalog_source)
|
||||
modules = _normalize_catalog_modules(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")
|
||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
||||
freshness = _catalog_freshness_state(payload)
|
||||
replay = _catalog_replay_state(channel=channel, sequence=sequence)
|
||||
state = _catalog_validation_state(catalog_source, trusted_keys=effective_trusted_keys)
|
||||
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,
|
||||
sequence=sequence,
|
||||
generated_at=generated_at,
|
||||
not_before=not_before,
|
||||
expires_at=expires_at,
|
||||
signature_state=signature_state,
|
||||
read_state=read_state,
|
||||
error=str(freshness["error"]),
|
||||
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)
|
||||
channel = _catalog_channel(payload)
|
||||
sequence = _catalog_sequence(payload)
|
||||
return _CatalogValidationState(
|
||||
modules=modules,
|
||||
channel=channel,
|
||||
sequence=sequence,
|
||||
generated_at=_catalog_optional_text(payload, "generated_at"),
|
||||
not_before=_catalog_optional_text(payload, "not_before"),
|
||||
expires_at=_catalog_optional_text(payload, "expires_at"),
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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 not replay["valid"]:
|
||||
return _invalid_catalog_result(
|
||||
catalog_source,
|
||||
modules=(),
|
||||
channel=channel,
|
||||
sequence=sequence,
|
||||
generated_at=generated_at,
|
||||
not_before=not_before,
|
||||
expires_at=expires_at,
|
||||
signature_state=signature_state,
|
||||
read_state=read_state,
|
||||
error=str(replay["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))
|
||||
if not signature_state["signed"]:
|
||||
warnings.append("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."))
|
||||
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(
|
||||
source,
|
||||
modules=(),
|
||||
channel=None,
|
||||
sequence=None,
|
||||
generated_at=None,
|
||||
not_before=None,
|
||||
expires_at=None,
|
||||
signature_state=_unsigned_catalog_signature_state(),
|
||||
read_state=_default_catalog_read_state(),
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _invalid_catalog_state_result(source: Path | str | None, state: _CatalogValidationState, *, error: str) -> dict[str, object]:
|
||||
return _invalid_catalog_result(
|
||||
source,
|
||||
modules=(),
|
||||
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 {
|
||||
"valid": True,
|
||||
"configured": catalog_source is not None and _catalog_source_exists(catalog_source),
|
||||
"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": list(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": warnings,
|
||||
"configured": source is not None and _catalog_source_exists(source),
|
||||
"path": str(source) if source is not None else None,
|
||||
"source": str(source) if source is not None else None,
|
||||
"source_type": _catalog_source_type(source),
|
||||
"cache_used": bool(state.read_state.get("cache_used")),
|
||||
"cache_path": state.read_state.get("cache_path") if isinstance(state.read_state.get("cache_path"), str) else None,
|
||||
"modules": list(state.modules),
|
||||
"channel": state.channel,
|
||||
"sequence": state.sequence,
|
||||
"generated_at": state.generated_at,
|
||||
"not_before": state.not_before,
|
||||
"expires_at": state.expires_at,
|
||||
"signed": state.signature_state["signed"],
|
||||
"trusted": state.signature_state["trusted"],
|
||||
"key_id": state.signature_state["key_id"],
|
||||
"warnings": _catalog_validation_warnings(state),
|
||||
"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(
|
||||
*,
|
||||
path: Path,
|
||||
@@ -350,17 +329,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
||||
|
||||
|
||||
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()
|
||||
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 = response.read().decode("utf-8")
|
||||
body = fetch_http_text(url, timeout=15, label="Trusted catalog key URL")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return body
|
||||
except (OSError, urllib.error.URLError):
|
||||
except OSError:
|
||||
if cache_path is not None and cache_path.exists():
|
||||
return cache_path.read_text(encoding="utf-8")
|
||||
raise
|
||||
@@ -421,13 +397,12 @@ def _read_catalog_url(url: str) -> str:
|
||||
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
|
||||
cache_path = _configured_catalog_cache_path()
|
||||
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 = response.read().decode("utf-8")
|
||||
body = fetch_http_text(url, timeout=15, label="Module package catalog URL")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return body, False
|
||||
except (OSError, urllib.error.URLError):
|
||||
except OSError:
|
||||
if cache_path is not None and cache_path.exists():
|
||||
return cache_path.read_text(encoding="utf-8"), True
|
||||
raise
|
||||
@@ -935,8 +910,7 @@ def _catalog_source_type(source: Path | str | None) -> str | None:
|
||||
|
||||
|
||||
def _is_http_url(value: str) -> bool:
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc) and not parsed.username and not parsed.password
|
||||
return is_http_url(value)
|
||||
|
||||
|
||||
def _invalid_catalog_result(
|
||||
|
||||
64
src/govoplan_core/core/notifications.py
Normal file
64
src/govoplan_core/core/notifications.py
Normal 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
|
||||
@@ -186,46 +186,20 @@ class PlatformRegistry:
|
||||
def validate(self) -> RegistrySnapshot:
|
||||
ordered = tuple(self._topologically_sorted())
|
||||
available_capabilities = set(self._capability_factories)
|
||||
seen_permissions: dict[str, PermissionDefinition] = {}
|
||||
for manifest in ordered:
|
||||
_validate_manifest_shape(manifest)
|
||||
for dependency in manifest.dependencies:
|
||||
if dependency not in self._manifests:
|
||||
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")
|
||||
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_manifest_relationships(
|
||||
manifest,
|
||||
known_modules=self._manifests,
|
||||
available_capabilities=available_capabilities,
|
||||
)
|
||||
permissions = _collect_manifest_permissions(ordered)
|
||||
_validate_interface_closure(ordered)
|
||||
|
||||
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}")
|
||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||
|
||||
return RegistrySnapshot(
|
||||
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),
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
_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):
|
||||
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
|
||||
if not manifest.name.strip():
|
||||
@@ -313,12 +360,17 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
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, "optional_dependencies", manifest.optional_dependencies)
|
||||
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
|
||||
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
||||
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
||||
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
||||
|
||||
|
||||
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
||||
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
||||
if overlap:
|
||||
joined = ", ".join(sorted(overlap))
|
||||
@@ -328,36 +380,42 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
joined = ", ".join(sorted(capability_overlap))
|
||||
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.module_id != manifest.id:
|
||||
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:
|
||||
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
|
||||
|
||||
if manifest.frontend is not None:
|
||||
frontend = manifest.frontend
|
||||
if frontend.module_id != manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
|
||||
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
|
||||
f"{frontend.asset_manifest_contract_version!r}; supported version is "
|
||||
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
|
||||
)
|
||||
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}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
if not route.path.startswith("/"):
|
||||
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:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
|
||||
for item in manifest.nav_items:
|
||||
def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
if manifest.frontend is None:
|
||||
return
|
||||
frontend = manifest.frontend
|
||||
if frontend.module_id != manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
|
||||
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
|
||||
f"{frontend.asset_manifest_contract_version!r}; supported version is "
|
||||
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
|
||||
)
|
||||
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}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||
for item in frontend.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:
|
||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
||||
for manifest in manifests:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
|
||||
_context: ModuleContext | None = None
|
||||
@@ -21,3 +23,41 @@ def get_runtime_context() -> ModuleContext | None:
|
||||
|
||||
def get_registry() -> object | 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
|
||||
|
||||
68
src/govoplan_core/core/sqlalchemy_change_tracking.py
Normal file
68
src/govoplan_core/core/sqlalchemy_change_tracking.py
Normal 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"
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
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.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Historic development databases could be created partly through Alembic and
|
||||
# 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
|
||||
@@ -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)
|
||||
class MigrationResult:
|
||||
previous_revision: str | None
|
||||
@@ -233,10 +245,7 @@ def run_registered_module_migration_tasks(
|
||||
dry_run: bool = False,
|
||||
manifest_factories: tuple[ManifestFactory, ...] = (),
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
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))
|
||||
active_phases = _normalized_migration_task_phases(phases)
|
||||
url = database_url or settings.database_url
|
||||
registry = _registered_module_registry(
|
||||
database_url=url,
|
||||
@@ -244,91 +253,192 @@ def run_registered_module_migration_tasks(
|
||||
manifest_factories=manifest_factories,
|
||||
)
|
||||
manifests = {manifest.id: manifest for manifest in registry.manifests()}
|
||||
ordered_ids = tuple(dict.fromkeys([
|
||||
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
|
||||
*manifests.keys(),
|
||||
]))
|
||||
ordered_ids = _ordered_migration_task_module_ids(migration_module_order, manifests)
|
||||
records: list[dict[str, object]] = []
|
||||
database = get_database()
|
||||
with database.SessionLocal() as session:
|
||||
for phase in active_phases:
|
||||
for module_id in ordered_ids:
|
||||
manifest = manifests.get(module_id)
|
||||
if manifest is None or manifest.migration_spec is None:
|
||||
continue
|
||||
for task in manifest.migration_spec.migration_tasks:
|
||||
if task.phase != phase:
|
||||
continue
|
||||
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
|
||||
if not task.idempotent:
|
||||
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:
|
||||
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
|
||||
records.append(record)
|
||||
continue
|
||||
if task.executor is None:
|
||||
record.update({"status": "blocked", "message": "Task has no executor."})
|
||||
records.append(record)
|
||||
raise ModuleMigrationTaskExecutionError(
|
||||
f"Module migration task {manifest.id}/{task.task_id} has no executor.",
|
||||
records=tuple(records),
|
||||
)
|
||||
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,
|
||||
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({
|
||||
"status": normalized.status,
|
||||
"message": normalized.message,
|
||||
"details": _jsonable_migration_task_details(normalized.details),
|
||||
})
|
||||
records.append(record)
|
||||
if normalized.status == "blocked":
|
||||
session.rollback()
|
||||
raise ModuleMigrationTaskExecutionError(
|
||||
normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.",
|
||||
records=tuple(records),
|
||||
)
|
||||
session.commit()
|
||||
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:
|
||||
manifest = manifests.get(module_id)
|
||||
migration_spec = getattr(manifest, "migration_spec", None)
|
||||
if manifest is None or migration_spec is None:
|
||||
continue
|
||||
for task in migration_spec.migration_tasks:
|
||||
if task.phase == phase:
|
||||
yield manifest, task
|
||||
|
||||
|
||||
def _run_registered_module_migration_task(
|
||||
session: object,
|
||||
*,
|
||||
manifest: object,
|
||||
task: object,
|
||||
database_url: str,
|
||||
dry_run: bool,
|
||||
records: list[dict[str, object]],
|
||||
) -> None:
|
||||
record = _migration_task_record(manifest, task, dry_run=dry_run)
|
||||
_validate_migration_task_can_run(manifest, task, record, records, dry_run=dry_run)
|
||||
if dry_run:
|
||||
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
|
||||
records.append(record)
|
||||
return
|
||||
normalized = _execute_module_migration_task(
|
||||
session,
|
||||
manifest=manifest,
|
||||
task=task,
|
||||
record=record,
|
||||
records=records,
|
||||
database_url=database_url,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
record.update({
|
||||
"status": normalized.status,
|
||||
"message": normalized.message,
|
||||
"details": _jsonable_migration_task_details(normalized.details),
|
||||
})
|
||||
records.append(record)
|
||||
if normalized.status == "blocked":
|
||||
session.rollback()
|
||||
raise ModuleMigrationTaskExecutionError(
|
||||
normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.",
|
||||
records=tuple(records),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
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:
|
||||
if result is None:
|
||||
return 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:
|
||||
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:
|
||||
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:
|
||||
quoted_old = _quoted_table_name(connection, old_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:
|
||||
@@ -715,7 +828,8 @@ def reconcile_covered_alembic_dependency_heads(
|
||||
for revision_id in current:
|
||||
try:
|
||||
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
|
||||
ancestors = {
|
||||
item.revision
|
||||
@@ -749,58 +863,68 @@ def reconcile_legacy_create_all_schema(
|
||||
"""
|
||||
|
||||
url = database_url or settings.database_url
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
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)
|
||||
tables = set(schema.get_table_names())
|
||||
|
||||
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
|
||||
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
|
||||
for table in _FILE_STORAGE_TABLES
|
||||
)
|
||||
has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns(
|
||||
schema,
|
||||
"file_folders",
|
||||
_FILE_STORAGE_COLUMNS["file_folders"],
|
||||
)
|
||||
has_create_all_hierarchical_schema = _has_create_all_schema_through_hierarchical_settings(schema, tables)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
with engine.connect() as connection:
|
||||
heads = MigrationContext.configure(connection).get_current_heads()
|
||||
schema = inspect(connection)
|
||||
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_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
|
||||
for table in _FILE_STORAGE_TABLES
|
||||
)
|
||||
has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns(
|
||||
schema,
|
||||
"file_folders",
|
||||
_FILE_STORAGE_COLUMNS["file_folders"],
|
||||
)
|
||||
return _LegacyCreateAllSchemaState(
|
||||
current=heads[0] if len(heads) == 1 else None,
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def migrate_database(
|
||||
*,
|
||||
database_url: str | None = None,
|
||||
|
||||
100
src/govoplan_core/db/query_metrics.py
Normal file
100
src/govoplan_core/db/query_metrics.py
Normal 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)
|
||||
@@ -7,6 +7,8 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import Engine
|
||||
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]:
|
||||
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))
|
||||
if 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:
|
||||
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
|
||||
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)
|
||||
|
||||
def session(self) -> Session:
|
||||
|
||||
@@ -67,6 +67,32 @@ class ImapConfig(ImapServerConfig):
|
||||
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:
|
||||
if security == TransportSecurity.TLS or security == "tls":
|
||||
return 465
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Compatibility facade for privacy retention policy.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
|
||||
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"
|
||||
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 = {
|
||||
"store_raw_campaign_json": True,
|
||||
"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:
|
||||
if key in raw:
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
service = _runtime_service()
|
||||
if service is not None:
|
||||
|
||||
83
src/govoplan_core/privacy/schemas.py
Normal file
83
src/govoplan_core/privacy/schemas.py
Normal 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",
|
||||
]
|
||||
66
src/govoplan_core/security/http_fetch.py
Normal file
66
src/govoplan_core/security/http_fetch.py
Normal 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)
|
||||
@@ -95,6 +95,11 @@ MODULE_SYSTEM_SCOPES = frozenset(
|
||||
for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
|
||||
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
|
||||
|
||||
|
||||
@@ -110,7 +115,7 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
|
||||
|
||||
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
|
||||
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(
|
||||
scope != "tenant:*" and scopes_grant([scope], alias)
|
||||
for scope in granted
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
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)
|
||||
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]] = {
|
||||
"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": [
|
||||
|
||||
55
src/govoplan_core/security/scope_aliases.py
Normal file
55
src/govoplan_core/security/scope_aliases.py
Normal 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",
|
||||
}),
|
||||
}
|
||||
@@ -38,8 +38,8 @@ def _normalize_fernet_key(value: str) -> bytes:
|
||||
try:
|
||||
Fernet(candidate)
|
||||
return candidate
|
||||
except Exception:
|
||||
pass
|
||||
except (TypeError, ValueError):
|
||||
raw = None
|
||||
try:
|
||||
raw = base64.b64decode(candidate)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -13,52 +13,13 @@ from govoplan_core.server.route_validation import validate_no_route_collisions
|
||||
|
||||
|
||||
def create_app(config: GovoplanServerConfig | str | None = None):
|
||||
if isinstance(config, str) or config is None:
|
||||
server_config = load_server_config(config)
|
||||
else:
|
||||
server_config = config
|
||||
|
||||
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,
|
||||
)
|
||||
server_config = _server_config(config)
|
||||
_configure_app_database(server_config)
|
||||
registry, available_modules = _server_module_registry(server_config)
|
||||
api_router = _server_api_router(server_config, registry)
|
||||
lifecycle = _server_lifecycle(server_config, registry=registry, available_modules=available_modules)
|
||||
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(
|
||||
title=server_config.title,
|
||||
version=server_config.version,
|
||||
@@ -74,4 +35,61 @@ def create_app(config: GovoplanServerConfig | str | None = None):
|
||||
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()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
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.registry import PlatformRegistry
|
||||
from govoplan_core.db.query_metrics import collect_query_metrics
|
||||
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
||||
|
||||
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(
|
||||
@@ -26,6 +39,7 @@ def create_govoplan_app(
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
||||
app.state.govoplan_registry = registry
|
||||
slow_request_threshold_ms = _slow_request_threshold_ms()
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_correlation_context(request: Request, call_next):
|
||||
@@ -39,6 +53,42 @@ def create_govoplan_app(
|
||||
response.headers["X-Correlation-ID"] = correlation_id
|
||||
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)
|
||||
|
||||
origins = [item.strip() for item in cors_origins if item.strip()]
|
||||
|
||||
@@ -17,15 +17,15 @@ class Settings(BaseSettings):
|
||||
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_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")
|
||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
||||
|
||||
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
|
||||
s3_region: str = Field(default="garage", alias="S3_REGION")
|
||||
s3_access_key_id: str = Field(default="GKmultimailerdev0000000000000000", alias="S3_ACCESS_KEY_ID")
|
||||
s3_secret_access_key: str = Field(default="multimailer-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY")
|
||||
s3_access_key_id: str = Field(default="GKgovoplandev0000000000000000000", alias="S3_ACCESS_KEY_ID")
|
||||
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")
|
||||
|
||||
# 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_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_csrf_cookie_name: str = Field(default="msm_csrf", alias="AUTH_CSRF_COOKIE_NAME")
|
||||
auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_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_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
|
||||
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
||||
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
||||
|
||||
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")
|
||||
|
||||
# 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_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED")
|
||||
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")
|
||||
|
||||
@@ -364,12 +364,79 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
json={"email": "admin@example.local", "password": "test-admin"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
csrf = response.cookies.get("msm_csrf")
|
||||
csrf = response.cookies.get("govoplan_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")
|
||||
self.assertEqual(me.status_code, 200, me.text)
|
||||
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"]["membership_id"], me_payload["user"]["id"])
|
||||
self.assertEqual(me_payload["principal"]["tenant_id"], me_payload["tenant"]["id"])
|
||||
@@ -423,6 +490,20 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
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(
|
||||
f"/api/v1/mail/profiles/{profile_id}/mailbox/messages",
|
||||
headers=headers,
|
||||
@@ -433,6 +514,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(payload["folder"], "INBOX")
|
||||
self.assertEqual(payload["total_count"], 3)
|
||||
self.assertEqual(len(payload["messages"]), 2)
|
||||
self.assertTrue(payload["from_cache"])
|
||||
self.assertTrue(all(message["folder"] == "INBOX" for message in payload["messages"]))
|
||||
self.assertTrue(payload["cursor_stable"])
|
||||
self.assertFalse(payload["full"])
|
||||
@@ -449,6 +531,14 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(len(next_payload["messages"]), 1)
|
||||
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(
|
||||
"mail.mailbox.messages.v1",
|
||||
fingerprint=keyset_query_fingerprint(
|
||||
@@ -739,7 +829,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
schema = self.client.get("/api/v1/schemas/campaign", headers=headers)
|
||||
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)
|
||||
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/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(
|
||||
f"/api/v1/campaigns/{campaign_id}/jobs",
|
||||
|
||||
27
tests/test_http_fetch.py
Normal file
27
tests/test_http_fetch.py
Normal 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
32
tests/test_mail_config.py
Normal 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()
|
||||
@@ -14,7 +14,6 @@ import tempfile
|
||||
import textwrap
|
||||
import tomllib
|
||||
import unittest
|
||||
import urllib.error
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -35,6 +34,7 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
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.module_management import (
|
||||
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.assertEqual(manifests["campaigns"].dependencies, ())
|
||||
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.assertTrue(manifests["dashboard"].required_capabilities)
|
||||
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(["access:tenant:read"], "system:tenants: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"))
|
||||
|
||||
def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
|
||||
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())
|
||||
for path in webui_src.rglob("*.ts*"):
|
||||
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:
|
||||
from govoplan_admin.backend.db.models import GovernanceTemplate
|
||||
@@ -2474,7 +2476,8 @@ finally:
|
||||
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
|
||||
|
||||
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=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("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:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-restore-check-", dir=_TEST_ROOT))
|
||||
settings = _settings(root)
|
||||
@@ -2654,11 +2694,13 @@ finally:
|
||||
queued = queue_module_installer_request(
|
||||
runtime_dir=runtime_dir,
|
||||
requested_by="user-1",
|
||||
tenant_id="tenant-1",
|
||||
options={"migrate_database": True, "health_urls": ["http://127.0.0.1:8000/health"]},
|
||||
)
|
||||
request_id = str(queued["request_id"])
|
||||
|
||||
self.assertEqual("queued", queued["status"])
|
||||
self.assertEqual("tenant-1", queued["tenant_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)))
|
||||
claimed = claim_next_module_installer_request(runtime_dir=runtime_dir)
|
||||
@@ -2675,7 +2717,7 @@ finally:
|
||||
self.assertEqual("completed", updated["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(
|
||||
runtime_dir=runtime_dir,
|
||||
request_id=str(cancellable["request_id"]),
|
||||
@@ -2691,6 +2733,7 @@ finally:
|
||||
)
|
||||
self.assertEqual("queued", retry["status"])
|
||||
self.assertEqual(cancellable["request_id"], retry["retry_of"])
|
||||
self.assertEqual("tenant-1", retry["tenant_id"])
|
||||
|
||||
settings = _settings(root)
|
||||
configure_database(settings.database_url)
|
||||
@@ -3030,11 +3073,11 @@ finally:
|
||||
"version_min": "0.1.0",
|
||||
"version_max_exclusive": "0.2.0",
|
||||
}, 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.assertIn("migration", modules["files"]["migration_notes"].lower())
|
||||
self.assertEqual("0.1.7", modules["files"]["version"])
|
||||
self.assertIn("@v0.1.7", modules["files"]["python_ref"])
|
||||
self.assertEqual("0.1.8", modules["files"]["version"])
|
||||
self.assertIn("@v0.1.8", modules["files"]["python_ref"])
|
||||
|
||||
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))
|
||||
@@ -3067,16 +3110,6 @@ finally:
|
||||
)
|
||||
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 = {
|
||||
"GOVOPLAN_MODULE_PACKAGE_CATALOG_URL": "https://catalog.example.invalid/stable.json",
|
||||
"GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE": str(cache_path),
|
||||
@@ -3085,11 +3118,11 @@ finally:
|
||||
}
|
||||
trusted_keys = {"release-remote": base64.b64encode(public_key).decode("ascii")}
|
||||
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)
|
||||
with patch(
|
||||
"govoplan_core.core.module_package_catalog.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("offline"),
|
||||
"govoplan_core.core.module_package_catalog.fetch_http_text",
|
||||
side_effect=OSError("offline"),
|
||||
):
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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_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_mail_settings())
|
||||
|
||||
|
||||
38
tests/test_privacy_schema_contracts.py
Normal file
38
tests/test_privacy_schema_contracts.py
Normal 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()
|
||||
61
tests/test_query_metrics.py
Normal file
61
tests/test_query_metrics.py
Normal 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()
|
||||
72
tests/test_sqlalchemy_change_tracking.py
Normal file
72
tests/test_sqlalchemy_change_tracking.py
Normal 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()
|
||||
45
webui/package-lock.json
generated
45
webui/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.8",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
||||
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
|
||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||
@@ -18,6 +19,7 @@
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/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": {
|
||||
"name": "@govoplan/admin-webui",
|
||||
"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": {
|
||||
"name": "@govoplan/ops-webui",
|
||||
"version": "0.1.8",
|
||||
@@ -1030,6 +1067,10 @@
|
||||
"resolved": "../../govoplan-access/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/addresses-webui": {
|
||||
"resolved": "../../govoplan-addresses/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/admin-webui": {
|
||||
"resolved": "../../govoplan-admin/webui",
|
||||
"link": true
|
||||
@@ -1066,6 +1107,10 @@
|
||||
"resolved": "../../govoplan-mail/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/notifications-webui": {
|
||||
"resolved": "../../govoplan-notifications/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/ops-webui": {
|
||||
"resolved": "../../govoplan-ops/webui",
|
||||
"link": true
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
|
||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||
@@ -36,6 +37,7 @@
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"dependencies": {
|
||||
"@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/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/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",
|
||||
@@ -31,6 +32,7 @@
|
||||
"@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/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/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",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
|
||||
const packageByModule = {
|
||||
access: "@govoplan/access-webui",
|
||||
admin: "@govoplan/admin-webui",
|
||||
addresses: "@govoplan/addresses-webui",
|
||||
audit: "@govoplan/audit-webui",
|
||||
campaigns: "@govoplan/campaign-webui",
|
||||
dashboard: "@govoplan/dashboard-webui",
|
||||
@@ -20,6 +21,7 @@ const cases = [
|
||||
{ name: "core-only", modules: [] },
|
||||
{ name: "access-only", modules: ["access"] },
|
||||
{ name: "admin-only", modules: ["admin"] },
|
||||
{ name: "addresses-only", modules: ["addresses"] },
|
||||
{ name: "access-with-admin", modules: ["access", "admin"] },
|
||||
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
|
||||
{ name: "dashboard-only", modules: ["dashboard"] },
|
||||
@@ -33,7 +35,7 @@ const cases = [
|
||||
{ name: "scheduling-only", modules: ["scheduling"] },
|
||||
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
||||
{ 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;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
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 { 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 PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
import LoginModal from "./features/auth/LoginModal";
|
||||
@@ -47,9 +47,9 @@ export default function App() {
|
||||
saveApiSettings(next);
|
||||
}
|
||||
|
||||
function updateAuth(next: AuthInfo | null, accessToken?: string) {
|
||||
function updateAuth(next: AuthUpdate | null, accessToken?: string) {
|
||||
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
|
||||
setAuth(next ? normalizeAuthInfo(next) : null);
|
||||
setAuth((current) => next ? normalizeAuthInfo(mergeAuthPayload(current, next)) : null);
|
||||
if (accessToken !== undefined) {
|
||||
setSettings(nextSettings);
|
||||
saveApiSettings(nextSettings);
|
||||
@@ -117,21 +117,26 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCheckingSession(true);
|
||||
fetchMe(settings).
|
||||
then((me) => {if (!cancelled) setAuth(normalizeAuthInfo(me));}).
|
||||
catch(() => {
|
||||
if (!cancelled) {
|
||||
const cleared = { ...settings, accessToken: "" };
|
||||
setSettings(cleared);
|
||||
saveApiSettings(cleared);
|
||||
setAuth(null);
|
||||
setPlatformModules(null);
|
||||
setRemoteWebModules([]);
|
||||
|
||||
async function bootstrapAuth() {
|
||||
try {
|
||||
const shellAuth = await fetchShellAuth(settings);
|
||||
if (!cancelled) setAuth(normalizeAuthInfo(shellAuth));
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
const cleared = { ...settings, accessToken: "" };
|
||||
setSettings(cleared);
|
||||
saveApiSettings(cleared);
|
||||
setAuth(null);
|
||||
setPlatformModules(null);
|
||||
setRemoteWebModules([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setCheckingSession(false);
|
||||
}
|
||||
}).
|
||||
finally(() => {
|
||||
if (!cancelled) setCheckingSession(false);
|
||||
});
|
||||
}
|
||||
|
||||
void bootstrapAuth();
|
||||
return () => {cancelled = true;};
|
||||
}, [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-reduce-motion", preferences.reduce_motion);
|
||||
root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars);
|
||||
if (preferences.theme === "system") {
|
||||
delete root.dataset.theme;
|
||||
} else {
|
||||
root.dataset.theme = preferences.theme;
|
||||
|
||||
const systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
|
||||
const applyTheme = () => {
|
||||
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?.show_inline_help_hints,
|
||||
@@ -208,8 +227,11 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (!auth) return;
|
||||
|
||||
const currentAuth = auth;
|
||||
let cancelled = false;
|
||||
let inFlight = false;
|
||||
let lastRefreshAt = 0;
|
||||
let lastShellRefreshAt = Date.now();
|
||||
|
||||
async function refreshVisibleSession() {
|
||||
if (document.visibilityState === "hidden" || inFlight) return;
|
||||
@@ -219,7 +241,17 @@ export default function App() {
|
||||
inFlight = true;
|
||||
lastRefreshAt = now;
|
||||
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 {
|
||||
|
||||
|
||||
@@ -230,6 +262,7 @@ export default function App() {
|
||||
window.addEventListener("focus", refreshVisibleSession);
|
||||
document.addEventListener("visibilitychange", refreshVisibleSession);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", refreshVisibleSession);
|
||||
document.removeEventListener("visibilitychange", refreshVisibleSession);
|
||||
};
|
||||
@@ -327,18 +360,40 @@ export default function App() {
|
||||
|
||||
}
|
||||
|
||||
type AuthPayload = Partial<AuthInfo> & {
|
||||
principal?: AuthInfo["principal"];
|
||||
user?: Partial<AuthUser> | null;
|
||||
tenant?: AuthTenant | null;
|
||||
active_tenant?: AuthTenant | null;
|
||||
tenants?: AuthTenantMembership[] | null;
|
||||
};
|
||||
type AuthPayload = AuthUpdate;
|
||||
|
||||
function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayload {
|
||||
if (!current) return next;
|
||||
const nextActiveTenant = next.active_tenant ?? next.tenant ?? 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 {
|
||||
const principal = response.principal ?? null;
|
||||
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
|
||||
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) {
|
||||
throw new Error("Authentication response did not include an active tenant.");
|
||||
@@ -356,12 +411,25 @@ function normalizeAuthInfo(response: AuthPayload): AuthInfo {
|
||||
roles: response.roles ?? [],
|
||||
groups: response.groups ?? [],
|
||||
principal,
|
||||
available_languages: response.available_languages ?? [],
|
||||
enabled_language_codes: response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [],
|
||||
default_language: response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en"
|
||||
available_languages: response.available_languages,
|
||||
enabled_language_codes: profileLoaded ? response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [] : undefined,
|
||||
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 {
|
||||
if (user?.id && user.account_id) {
|
||||
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 {
|
||||
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
|
||||
return {
|
||||
|
||||
56
webui/src/api/adminCommon.ts
Normal file
56
webui/src/api/adminCommon.ts
Normal 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");
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
export async function login(
|
||||
@@ -15,8 +15,28 @@ export async function fetchMe(settings: ApiSettings): Promise<AuthInfo> {
|
||||
return apiFetch<AuthInfo>(settings, "/api/v1/auth/me");
|
||||
}
|
||||
|
||||
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthInfo> {
|
||||
return apiFetch<AuthInfo>(settings, "/api/v1/auth/switch-tenant", {
|
||||
export async function fetchSession(settings: ApiSettings): Promise<AuthSessionInfo> {
|
||||
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",
|
||||
body: JSON.stringify({ tenant_id: tenantId })
|
||||
});
|
||||
@@ -35,8 +55,8 @@ export async function updateProfile(
|
||||
enabled_language_codes?: string[] | null;
|
||||
ui_preferences?: Partial<UserUiPreferences> | null;
|
||||
}
|
||||
): Promise<AuthInfo> {
|
||||
return apiFetch<AuthInfo>(settings, "/api/v1/auth/profile", {
|
||||
): Promise<AuthProfileInfo> {
|
||||
return apiFetch<AuthProfileInfo>(settings, "/api/v1/auth/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
|
||||
const STORAGE_KEY = "multimailer.apiSettings";
|
||||
const LEGACY_STORAGE_KEYS = ["i18n:govoplan-core.multimailer_apisettings.1d1601d4"];
|
||||
const SESSION_STORAGE_KEY = "multimailer.session";
|
||||
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
|
||||
const STORAGE_KEY = "govoplan.apiSettings";
|
||||
const LEGACY_STORAGE_KEYS: string[] = [];
|
||||
const SESSION_STORAGE_KEY = "govoplan.session";
|
||||
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "govoplan_csrf";
|
||||
const RECENT_SAFE_REQUEST_TTL_MS = 750;
|
||||
const MAX_RECENT_SAFE_REQUESTS = 100;
|
||||
const MAX_CONDITIONAL_SAFE_REQUESTS = 200;
|
||||
@@ -77,6 +77,56 @@ export function apiUrl(settings: ApiSettings, path: string): string {
|
||||
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 {
|
||||
const storedBaseUrl = loadStoredSetting("baseUrl");
|
||||
const storedApiKey = sessionStorage.getItem(`${SESSION_STORAGE_KEY}.apiKey`);
|
||||
|
||||
143
webui/src/api/mailContracts.ts
Normal file
143
webui/src/api/mailContracts.ts
Normal 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;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
import { apiFetch, apiPath } from "./client";
|
||||
|
||||
export type PrivacyRetentionPolicyFieldKey =
|
||||
| "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(
|
||||
settings: ApiSettings,
|
||||
scope: PrivacyRetentionPolicyScope,
|
||||
@@ -92,7 +85,7 @@ export function getPrivacyRetentionPolicy(
|
||||
): Promise<PrivacyRetentionPolicyScopeResponse> {
|
||||
return apiFetch(
|
||||
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> {
|
||||
return apiFetch(
|
||||
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> {
|
||||
return apiFetch(
|
||||
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 }) }
|
||||
);
|
||||
}
|
||||
|
||||
103
webui/src/components/CredentialPanel.tsx
Normal file
103
webui/src/components/CredentialPanel.tsx
Normal 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);
|
||||
}
|
||||
21
webui/src/components/DisabledActionTooltip.tsx
Normal file
21
webui/src/components/DisabledActionTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
webui/src/components/HoverTooltip.tsx
Normal file
228
webui/src/components/HoverTooltip.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 { createPortal } from "react-dom";
|
||||
import Button from "../Button";
|
||||
@@ -15,6 +16,7 @@ type EmailAddressInputProps = {
|
||||
value: MailboxAddress[];
|
||||
onChange?: (value: MailboxAddress[]) => void;
|
||||
onAddressAdded?: (address: MailboxAddress) => void;
|
||||
onSuggestionQueryChange?: (query: string) => void;
|
||||
suggestions?: MailboxAddress[];
|
||||
allowMultiple?: boolean;
|
||||
clearOnAdd?: boolean;
|
||||
@@ -31,6 +33,7 @@ export default function EmailAddressInput({
|
||||
value,
|
||||
onChange,
|
||||
onAddressAdded,
|
||||
onSuggestionQueryChange,
|
||||
suggestions = [],
|
||||
allowMultiple = true,
|
||||
clearOnAdd = false,
|
||||
@@ -42,6 +45,7 @@ export default function EmailAddressInput({
|
||||
compact = false,
|
||||
showAddButton
|
||||
}: EmailAddressInputProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const inputId = useId();
|
||||
const normalizedValue = useMemo(() => dedupeAddresses(value), [value]);
|
||||
const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]);
|
||||
@@ -51,9 +55,18 @@ export default function EmailAddressInput({
|
||||
const [dialogEmail, setDialogEmail] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
|
||||
const lastSuggestionQueryRef = useRef<string | null>(null);
|
||||
const addButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
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 query = entryText.trim().toLowerCase();
|
||||
if (!query) return normalizedSuggestions.slice(0, 6);
|
||||
@@ -168,18 +181,18 @@ export default function EmailAddressInput({
|
||||
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>
|
||||
<span>i18n:govoplan-core.name.709a2322</span>
|
||||
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus />
|
||||
<span>{translateText("i18n:govoplan-core.name.709a2322")}</span>
|
||||
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={translateText(namePlaceholder)} autoFocus />
|
||||
</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" />
|
||||
</label>
|
||||
<div className="button-row compact-actions">
|
||||
<Button type="button" onClick={() => setDialogOpen(false)}>i18n:govoplan-core.cancel.77dfd213</Button>
|
||||
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button>
|
||||
<Button type="button" onClick={() => setDialogOpen(false)}>{translateText("i18n:govoplan-core.cancel.77dfd213")}</Button>
|
||||
<Button type="button" variant="primary" onClick={commitDialogAddress}>{translateText(addLabel)}</Button>
|
||||
</div>
|
||||
</div>,
|
||||
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-editor ${error ? "has-error" : ""}`}>
|
||||
<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) => {
|
||||
const valid = isValidEmailAddress(address.email);
|
||||
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>
|
||||
{address.name && <span className="email-chip-address">{address.email}</span>}
|
||||
{!disabled &&
|
||||
@@ -217,11 +230,11 @@ export default function EmailAddressInput({
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={handleTextKeyDown}
|
||||
placeholder={i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: namePlaceholder, value1: emailPlaceholder })}
|
||||
aria-label="i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0" />
|
||||
placeholder={translateText(i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: translateText(namePlaceholder), value1: emailPlaceholder }))}
|
||||
aria-label={translateText("i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0")} />
|
||||
|
||||
{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>
|
||||
}
|
||||
@@ -230,7 +243,7 @@ export default function EmailAddressInput({
|
||||
</div>
|
||||
|
||||
{!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) =>
|
||||
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
|
||||
<span>{addressDisplayName(item)}</span>
|
||||
@@ -239,7 +252,7 @@ export default function EmailAddressInput({
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
{error && <p className="form-help danger-text">{error}</p>}
|
||||
{error && <p className="form-help danger-text">{translateText(error)}</p>}
|
||||
{addressDialog}
|
||||
</div>);
|
||||
|
||||
|
||||
@@ -1,188 +1,20 @@
|
||||
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";
|
||||
import type { ReactNode } from "react";
|
||||
import HoverTooltip from "../HoverTooltip";
|
||||
|
||||
type InlineHelpProps = {
|
||||
children: ReactNode;
|
||||
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) {
|
||||
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;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className={`inline-help ${className}`.trim()}
|
||||
tabIndex={-1}
|
||||
aria-label={translateText("i18n:govoplan-core.show_field_help.e3dfe98f")}
|
||||
aria-describedby={isOpen ? tooltipId : undefined}
|
||||
onMouseEnter={openWithDelay}
|
||||
onMouseLeave={close}
|
||||
onFocus={openWithDelay}
|
||||
onBlur={close}>
|
||||
|
||||
<span className="inline-help-mark" aria-hidden="true">?</span>
|
||||
</span>
|
||||
{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
|
||||
)}
|
||||
</>);
|
||||
|
||||
<HoverTooltip
|
||||
content={children}
|
||||
className={`inline-help ${className}`.trim()}
|
||||
ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f"
|
||||
triggerTabIndex={-1}>
|
||||
<span className="inline-help-mark" aria-hidden="true">?</span>
|
||||
</HoverTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "../Button";
|
||||
import { CredentialFields } from "../CredentialPanel";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
import FormField from "../FormField";
|
||||
import PasswordField from "../PasswordField";
|
||||
import SegmentedControl from "../SegmentedControl";
|
||||
import ToggleSwitch from "../ToggleSwitch";
|
||||
|
||||
@@ -48,6 +48,9 @@ export type MailServerFolderLookupResult = {
|
||||
details?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type MailServerSettingsSection = "smtp" | "imap" | "advanced";
|
||||
export type MailServerSettingsMode = "all" | "server" | "credentials";
|
||||
|
||||
export type MailServerSettingsPanelProps = {
|
||||
smtp: MailServerSmtpSettings;
|
||||
imap: MailServerImapSettings;
|
||||
@@ -97,12 +100,13 @@ export type MailServerSettingsPanelProps = {
|
||||
className?: string;
|
||||
floatingResults?: boolean;
|
||||
initialSection?: MailServerSettingsSection;
|
||||
visibleSections?: readonly MailServerSettingsSection[];
|
||||
mode?: MailServerSettingsMode;
|
||||
};
|
||||
|
||||
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
|
||||
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
|
||||
const securityOptions = mailServerSecurityOptions;
|
||||
type MailServerSettingsSection = "smtp" | "imap" | "advanced";
|
||||
|
||||
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
|
||||
if (security === "tls") return 465;
|
||||
@@ -234,7 +238,9 @@ export default function MailServerSettingsPanel({
|
||||
disabled = false,
|
||||
className = "",
|
||||
floatingResults = false,
|
||||
initialSection = "smtp"
|
||||
initialSection = "smtp",
|
||||
visibleSections,
|
||||
mode = "all"
|
||||
}: MailServerSettingsPanelProps) {
|
||||
const smtpFieldsDisabled = disabled || smtpDisabled;
|
||||
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
|
||||
@@ -254,11 +260,29 @@ export default function MailServerSettingsPanel({
|
||||
const appendTargetHelp = append ?
|
||||
"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";
|
||||
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(initialSection);
|
||||
const sections: {id: MailServerSettingsSection;label: string;}[] = [
|
||||
const allSections: {id: MailServerSettingsSection;label: string;}[] = [
|
||||
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
|
||||
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" },
|
||||
{ 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) {
|
||||
@@ -283,6 +307,7 @@ export default function MailServerSettingsPanel({
|
||||
|
||||
return (
|
||||
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
||||
{showSectionSwitcher &&
|
||||
<SegmentedControl
|
||||
className="mail-server-segmented-control"
|
||||
size="equal"
|
||||
@@ -291,27 +316,30 @@ export default function MailServerSettingsPanel({
|
||||
onChange={setActiveSection}
|
||||
options={sections}
|
||||
/>
|
||||
}
|
||||
|
||||
<div className="mail-server-settings-view">
|
||||
{activeSection === "smtp" &&
|
||||
<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">
|
||||
{showServerFields &&
|
||||
<>
|
||||
<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.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>
|
||||
<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">
|
||||
<PasswordField
|
||||
value={stringValue(smtpCredentialValues.password)}
|
||||
</>
|
||||
}
|
||||
{showCredentialFields &&
|
||||
<CredentialFields
|
||||
values={smtpCredentialValues}
|
||||
onChange={patchSmtpCredentials}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
saved={smtpPasswordSaved}
|
||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchSmtpCredentials({ password })} />
|
||||
|
||||
</FormField>
|
||||
savedPassword={smtpPasswordSaved}
|
||||
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
heading="i18n:govoplan-core.credentials.dd097a22"
|
||||
headingClassName="mail-server-field-heading" />
|
||||
}
|
||||
</div>
|
||||
{onTestSmtp &&
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
@@ -325,22 +353,24 @@ export default function MailServerSettingsPanel({
|
||||
{activeSection === "imap" &&
|
||||
<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">
|
||||
{showServerFields &&
|
||||
<>
|
||||
<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.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>
|
||||
<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">
|
||||
<PasswordField
|
||||
value={stringValue(imapCredentialValues.password)}
|
||||
</>
|
||||
}
|
||||
{showCredentialFields &&
|
||||
<CredentialFields
|
||||
values={imapCredentialValues}
|
||||
onChange={patchImapCredentials}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
saved={imapPasswordSaved}
|
||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchImapCredentials({ password })} />
|
||||
|
||||
</FormField>
|
||||
savedPassword={imapPasswordSaved}
|
||||
savedPasswordPlaceholder={imapSavedPasswordPlaceholder}
|
||||
heading="i18n:govoplan-core.credentials.dd097a22"
|
||||
headingClassName="mail-server-field-heading" />
|
||||
}
|
||||
</div>
|
||||
{onTestImap &&
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
|
||||
@@ -150,7 +150,7 @@ type ColumnResizeState = {
|
||||
behavior: DataGridResizeBehavior;
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = "multimailer.datagrid.";
|
||||
const STORAGE_PREFIX = "govoplan.datagrid.";
|
||||
const FILTER_POPOVER_WIDTH = 320;
|
||||
const FILTER_POPOVER_MARGIN = 12;
|
||||
const MIN_HEADER_LABEL_WIDTH = 72;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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 FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
@@ -8,15 +8,16 @@ import Button from "../../components/Button";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
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 DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import SegmentedControl from "../../components/SegmentedControl";
|
||||
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
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 = {
|
||||
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" }
|
||||
];
|
||||
|
||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean): ModuleSubnavGroup<SettingsSection>[] {
|
||||
return [
|
||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
|
||||
const groups: ModuleSubnavGroup<SettingsSection>[] = [
|
||||
{
|
||||
title: "i18n:govoplan-core.account.f967543b",
|
||||
items: [
|
||||
@@ -47,11 +48,29 @@ function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boole
|
||||
items: [
|
||||
{ id: "interface", label: "i18n:govoplan-core.interface.7b4db7ef" },
|
||||
{ id: "workspace", label: "i18n:govoplan-core.workspace.4ca0a75c" },
|
||||
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" },
|
||||
{ id: "notifications", label: "i18n:govoplan-core.notifications.753a22b2" }]
|
||||
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" }]
|
||||
|
||||
}];
|
||||
|
||||
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({
|
||||
@@ -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 { requestNavigation } = useUnsavedChanges();
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
|
||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? 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 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 requestedSection = searchParams.get("section") as SettingsSection | null;
|
||||
const active: SettingsSection = settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface";
|
||||
const contributedSections = useMemo(
|
||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||
[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 [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState("");
|
||||
@@ -118,12 +144,30 @@ export default function SettingsPage({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedSection && !settingsSectionAvailable(settingsSubnav, requestedSection)) {
|
||||
if (requestedSection && !settingsSectionAvailable(availableSectionIds, requestedSection)) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("section", "interface");
|
||||
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(() => {
|
||||
setProfileName(auth.user.display_name || "");
|
||||
@@ -329,12 +373,16 @@ export default function SettingsPage({
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-core.theme.a797e309">
|
||||
<select value={theme} onChange={(event) => setTheme(event.target.value as UserUiTheme)}>
|
||||
{UI_THEME_OPTIONS.map((item) =>
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
)}
|
||||
</select>
|
||||
<SegmentedControl
|
||||
options={UI_THEME_OPTIONS.map((item) => ({ id: item.value, label: item.label }))}
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
role="group"
|
||||
size="equal"
|
||||
width="fill"
|
||||
ariaLabel="i18n:govoplan-core.theme.a797e309" />
|
||||
</FormField>
|
||||
<ThemePreview theme={theme} />
|
||||
<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.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div>
|
||||
@@ -413,24 +461,15 @@ export default function SettingsPage({
|
||||
</div>
|
||||
}
|
||||
|
||||
{active === "notifications" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.notification_preferences.0ead6c12">
|
||||
<p className="muted">i18n:govoplan-core.prepared_for_later_personal_notification_prefere.3fe73f86</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>i18n:govoplan-core.in_app_completion_notices.b68f2f4c</span>
|
||||
<span>i18n:govoplan-core.email_summary_preferences.b6c1dfb1</span>
|
||||
<span>i18n:govoplan-core.failure_and_warning_alerts.939d21c2</span>
|
||||
</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>
|
||||
{activeContributedSection &&
|
||||
activeContributedSection.render({
|
||||
settings,
|
||||
auth,
|
||||
onAuthChange,
|
||||
activeSection: active,
|
||||
availableSections: availableSectionIds,
|
||||
selectSection
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -439,8 +478,30 @@ export default function SettingsPage({
|
||||
}
|
||||
|
||||
|
||||
function settingsSectionAvailable(groups: ModuleSubnavGroup<SettingsSection>[], section: SettingsSection | null | undefined): section is SettingsSection {
|
||||
return Boolean(section && groups.some((group) => group.items.some((item) => "id" in item && item.id === section)));
|
||||
function settingsSectionAvailable(sections: ReadonlySet<string>, section: string | null | undefined): section is SettingsSection {
|
||||
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 {
|
||||
@@ -457,3 +518,24 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
|
||||
function themeLabel(value: UserUiTheme): string {
|
||||
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>);
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,26 @@ export * from "./types";
|
||||
export * from "./api/client";
|
||||
export * from "./api/auth";
|
||||
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 "./platform/modules";
|
||||
export * from "./platform/ModuleContext";
|
||||
@@ -32,8 +52,12 @@ export { default as ConfirmDialog } from "./components/ConfirmDialog";
|
||||
export { default as ConnectionTree } from "./components/ConnectionTree";
|
||||
export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree";
|
||||
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 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 EffectivePolicyBlock, EffectivePolicyValue } 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 { default as GuidedReviewList } 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 LoadingIndicator } from "./components/LoadingIndicator";
|
||||
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 { 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 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 InlineHelp } from "./components/help/InlineHelp";
|
||||
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 Titlebar from "./Titlebar";
|
||||
import BreadcrumbBar from "./BreadcrumbBar";
|
||||
@@ -9,7 +9,7 @@ type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo | null;
|
||||
onSettingsChange: (settings: ApiSettings) => void;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
publicMode?: boolean;
|
||||
navItems?: PlatformNavItem[];
|
||||
maintenanceMode?: { enabled: boolean; message?: string | null };
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { PlatformWebModule } from "../types";
|
||||
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de";
|
||||
|
||||
export default function HelpMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
@@ -50,8 +52,11 @@ export default function HelpMenu() {
|
||||
}
|
||||
|
||||
function openDocs(type: "user" | "admin") {
|
||||
if (!docsAvailable) return;
|
||||
setOpen(false);
|
||||
if (!docsAvailable) {
|
||||
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ type });
|
||||
params.set("context", helpContext.id);
|
||||
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>
|
||||
</button>
|
||||
<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")}
|
||||
</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")}
|
||||
</button>
|
||||
<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;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import type { AuthInfo, PlatformNavItem } from "../types";
|
||||
@@ -7,6 +7,7 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
|
||||
|
||||
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
|
||||
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
|
||||
|
||||
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
|
||||
return [...navItems].
|
||||
@@ -22,14 +23,11 @@ export default function IconRail({
|
||||
compact = false,
|
||||
auth = null,
|
||||
navItems = []
|
||||
|
||||
|
||||
|
||||
|
||||
}: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) {
|
||||
const location = useLocation();
|
||||
const items = visibleNavItems(auth, navItems);
|
||||
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
|
||||
const [expanded, setExpanded] = useState(() => loadRailExpanded());
|
||||
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const navigate = useGuardedNavigate();
|
||||
@@ -46,15 +44,27 @@ export default function IconRail({
|
||||
});
|
||||
}, [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) {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
navigate(target);
|
||||
}
|
||||
|
||||
const railExpanded = !compact && expanded;
|
||||
|
||||
return (
|
||||
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
|
||||
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
|
||||
<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>
|
||||
|
||||
{!compact &&
|
||||
<>
|
||||
@@ -62,9 +72,11 @@ export default function IconRail({
|
||||
{items.map(({ to, label, icon: Icon }) => {
|
||||
const target = rememberedTargets[to] ?? to;
|
||||
const active = modulePathActive(location.pathname, to);
|
||||
const renderedLabel = translateText(label);
|
||||
return (
|
||||
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={translateText(label)} onClick={(event) => handleNavClick(event, target)}>
|
||||
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
|
||||
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={renderedLabel} onClick={(event) => handleNavClick(event, target)}>
|
||||
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
|
||||
<span className="icon-nav-label">{renderedLabel}</span>
|
||||
</NavLink>);
|
||||
|
||||
})}
|
||||
@@ -72,7 +84,17 @@ export default function IconRail({
|
||||
<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")}>
|
||||
<Settings size={20} />
|
||||
<span className="icon-nav-label">{translateText("i18n:govoplan-core.settings.c7f73bb5")}</span>
|
||||
</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>
|
||||
</>
|
||||
}
|
||||
@@ -107,7 +129,23 @@ function saveRememberedTargets(targets: Record<string, string>): void {
|
||||
try {
|
||||
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
|
||||
} catch {
|
||||
|
||||
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from "../types";
|
||||
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse } from "../types";
|
||||
import HelpMenu from "./HelpMenu";
|
||||
import LanguageMenu from "./LanguageMenu";
|
||||
import LoginModal from "../features/auth/LoginModal";
|
||||
import DismissibleAlert from "../components/DismissibleAlert";
|
||||
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
||||
import { apiFetch, isApiError } from "../api/client";
|
||||
import { logout, switchTenant } from "../api/auth";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { usePlatformModules } from "../platform/ModuleContext";
|
||||
|
||||
type NotificationSummary = {
|
||||
unread: number;
|
||||
show_unread_badge?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo | null;
|
||||
onSettingsChange: (settings: ApiSettings) => void;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
maintenanceMode?: {enabled: boolean;message?: string | null;};
|
||||
};
|
||||
|
||||
@@ -26,9 +33,11 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null);
|
||||
const [tenantError, setTenantError] = useState("");
|
||||
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
|
||||
const accountRef = useRef<HTMLDivElement>(null);
|
||||
const tenantRef = useRef<HTMLDivElement>(null);
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const modules = usePlatformModules();
|
||||
|
||||
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||
@@ -41,6 +50,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
"system:tenants:suspend"]
|
||||
);
|
||||
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(() => {
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
@@ -56,6 +67,41 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
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) {
|
||||
onAuthChange(response, "");
|
||||
}
|
||||
@@ -109,6 +155,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
navigate("/admin?section=system-settings");
|
||||
}
|
||||
|
||||
function openNotificationCenter() {
|
||||
navigate("/notifications");
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="titlebar">
|
||||
{maintenanceMode?.enabled &&
|
||||
@@ -159,8 +209,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
|
||||
<div className="titlebar-spacer" />
|
||||
|
||||
<HelpMenu />
|
||||
<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}>
|
||||
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
|
||||
|
||||
@@ -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 type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
||||
import {
|
||||
@@ -39,9 +39,12 @@ type RemoteAssetManifest = {
|
||||
const iconByName: Record<string, LucideIcon> = {
|
||||
activity: Activity,
|
||||
admin: Shield,
|
||||
bell: Bell,
|
||||
"book-user": BookUser,
|
||||
building: Building2,
|
||||
"building-2": Building2,
|
||||
calendar: CalendarDays,
|
||||
"calendar-clock": CalendarClock,
|
||||
campaign: Mails,
|
||||
"clipboard-pen-line": ClipboardPenLine,
|
||||
dashboard: LayoutDashboard,
|
||||
@@ -51,9 +54,12 @@ const iconByName: Record<string, LucideIcon> = {
|
||||
form: Form,
|
||||
"layout-template": LayoutTemplate,
|
||||
mail: Mail,
|
||||
notifications: Bell,
|
||||
organizations: Building2,
|
||||
operator: RadioTower,
|
||||
"radio-tower": RadioTower,
|
||||
reports: FileText,
|
||||
reports: ClipboardPenLine,
|
||||
templates: LayoutTemplate,
|
||||
users: Users
|
||||
};
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at 18% 18%, rgba(239, 107, 58, .13), transparent 24rem),
|
||||
radial-gradient(circle at 78% 14%, rgba(126, 166, 197, .15), transparent 22rem),
|
||||
radial-gradient(circle at 18% 18%, var(--accent-auth-glow), transparent 24rem),
|
||||
radial-gradient(circle at 78% 14%, var(--blue-auth-glow), transparent 22rem),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
.public-card {
|
||||
width: min(720px, 100%);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 42px 48px;
|
||||
@@ -134,9 +134,9 @@
|
||||
.titlebar-link:hover,
|
||||
.account-pill:hover,
|
||||
.context-menu-wrap:focus-within .account-pill {
|
||||
background: rgba(0,0,0,.055);
|
||||
background: var(--titlebar-hover-bg);
|
||||
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 {
|
||||
@@ -151,7 +151,7 @@
|
||||
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item.active {
|
||||
background: rgba(239, 107, 58, .10);
|
||||
background: var(--accent-hover-bg);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
|
||||
@@ -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-ready, .status-sent, .status-appended, .status-success, .status-active { background: #d6eee9; color: #34796d; }
|
||||
.status-warning, .status-needs-review, .status-pending { background: #ffedc6; color: #a06b00; }
|
||||
.status-blocked, .status-failed, .status-failed-permanent { background: #f8d1cc; color: #b13e35; }
|
||||
.status-queued, .status-sending { background: #d8e8f4; color: #386a90; }
|
||||
.status-inactive, .status-locked { background: #e7e4df; color: #666; }
|
||||
.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: var(--success-soft); color: var(--success-text-strong); }
|
||||
.status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); }
|
||||
.status-blocked, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
|
||||
.status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); }
|
||||
.status-inactive, .status-locked { background: var(--status-neutral-bg); color: var(--text-soft); }
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 13px;
|
||||
border: 2px solid #8c8881;
|
||||
border: 2px solid var(--loading-line);
|
||||
border-radius: 3px;
|
||||
background: rgba(255,255,255,.7);
|
||||
background: var(--loading-envelope-bg);
|
||||
animation: loading-envelope-float .74s ease-in-out infinite alternate;
|
||||
}
|
||||
.loading-envelope::before,
|
||||
@@ -31,7 +31,7 @@
|
||||
top: 1px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-top: 2px solid #8c8881;
|
||||
border-top: 2px solid var(--loading-line);
|
||||
}
|
||||
.loading-envelope::before {
|
||||
left: 1px;
|
||||
@@ -64,19 +64,19 @@
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
max-width: 100%;
|
||||
border: 1px solid #c9c5bd;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(#ffffff, #f2f1ef);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.85), 0 1px 1px rgba(0,0,0,.05);
|
||||
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end));
|
||||
box-shadow: var(--shadow-control-strong);
|
||||
padding: 5px 8px 5px 11px;
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.email-chip.invalid {
|
||||
border-color: #c96b63;
|
||||
background: #f6e3df;
|
||||
color: #873c35;
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger-text-deep);
|
||||
}
|
||||
.email-chip-main {
|
||||
overflow: hidden;
|
||||
@@ -99,14 +99,14 @@
|
||||
height: 18px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(0,0,0,.08);
|
||||
color: #57534d;
|
||||
background: var(--hover-tint);
|
||||
color: var(--control-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
.email-chip-remove:hover { background: rgba(0,0,0,.15); }
|
||||
.email-chip-remove:hover { background: var(--hover-tint-strong); }
|
||||
.email-chip-empty {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
@@ -148,7 +148,7 @@
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
@@ -224,14 +224,40 @@
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.password-field-toggle:hover {
|
||||
background: rgba(0, 0, 0, .06);
|
||||
background: var(--hover-tint-soft);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.password-field-toggle:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--blue) 55%, #fff);
|
||||
outline: var(--focus-outline);
|
||||
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,
|
||||
.date-field,
|
||||
.time-field {
|
||||
@@ -261,9 +287,9 @@
|
||||
z-index: 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--line-dark);
|
||||
border: var(--border-line-dark);
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
|
||||
box-shadow: var(--shadow-inset-highlight);
|
||||
}
|
||||
|
||||
.color-picker-trigger,
|
||||
@@ -287,7 +313,7 @@
|
||||
.color-picker-trigger:focus-visible,
|
||||
.date-field-trigger:hover:not(:disabled),
|
||||
.date-field-trigger:focus-visible {
|
||||
background: rgba(0,0,0,.08);
|
||||
background: var(--hover-tint);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
@@ -307,10 +333,10 @@
|
||||
width: max-content;
|
||||
min-width: 220px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line-dark);
|
||||
border: var(--border-line-dark);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 16px 34px rgba(0,0,0,.18);
|
||||
box-shadow: var(--shadow-menu);
|
||||
}
|
||||
|
||||
.color-picker-grid {
|
||||
@@ -322,10 +348,10 @@
|
||||
.color-picker-grid button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(0,0,0,.2);
|
||||
border: 1px solid var(--black-border-soft);
|
||||
border-radius: 6px;
|
||||
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 {
|
||||
@@ -355,7 +381,7 @@
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
@@ -401,9 +427,9 @@
|
||||
}
|
||||
|
||||
.date-field-grid button.is-selected {
|
||||
border-color: #5aa99b;
|
||||
border-color: var(--success-border);
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
color: var(--on-accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -426,7 +452,7 @@
|
||||
}
|
||||
|
||||
.admin-overview-link {
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
padding: 14px;
|
||||
@@ -436,7 +462,7 @@
|
||||
}
|
||||
|
||||
.admin-overview-link:hover {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border-color: var(--line-dark);
|
||||
}
|
||||
|
||||
@@ -467,7 +493,7 @@
|
||||
grid-template-columns: minmax(0, 1fr) 170px;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
@@ -551,7 +577,7 @@
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
@@ -616,7 +642,7 @@
|
||||
.module-install-preflight {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
padding: 12px 14px;
|
||||
@@ -657,7 +683,7 @@
|
||||
min-height: 30px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,.58);
|
||||
background: var(--panel-glass);
|
||||
}
|
||||
|
||||
.module-install-preflight-issue strong {
|
||||
@@ -686,9 +712,9 @@
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
min-height: 86px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,.6);
|
||||
background: var(--panel-glass);
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
@@ -743,7 +769,7 @@
|
||||
grid-template-columns: 130px 150px 170px minmax(260px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
@@ -818,7 +844,7 @@
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 34%);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
@@ -976,9 +1002,9 @@
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted, #f6f7f9);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.admin-selection-item {
|
||||
@@ -992,7 +1018,7 @@
|
||||
}
|
||||
|
||||
.admin-selection-item:hover {
|
||||
background: var(--surface, #fff);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.admin-selection-item.disabled {
|
||||
@@ -1036,8 +1062,8 @@
|
||||
|
||||
.guided-config-sidebar {
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: #f6f5f3;
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.guided-config-sidebar .stepper {
|
||||
@@ -1109,7 +1135,7 @@
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
padding: 15px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 7px;
|
||||
background: var(--panel);
|
||||
}
|
||||
@@ -1134,7 +1160,7 @@
|
||||
}
|
||||
|
||||
.advanced-options-panel {
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 7px;
|
||||
background: var(--panel);
|
||||
}
|
||||
@@ -1248,7 +1274,7 @@
|
||||
}
|
||||
|
||||
.action-blocker-technical {
|
||||
background: rgba(255,255,255,.56);
|
||||
background: var(--panel-glass);
|
||||
}
|
||||
|
||||
.guided-review-list {
|
||||
@@ -1271,7 +1297,7 @@
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-left-width: 4px;
|
||||
border-left-color: var(--line-dark);
|
||||
border-radius: 7px;
|
||||
@@ -1327,7 +1353,7 @@
|
||||
|
||||
.guided-config-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.guided-config-sidebar .stepper {
|
||||
@@ -1350,19 +1376,19 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 2px rgba(0,0,0,.035);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-control-surface);
|
||||
padding: 7px 8px;
|
||||
}
|
||||
.email-address-editor:focus-within {
|
||||
border-color: #9bb7d3;
|
||||
box-shadow: 0 0 0 3px rgba(82, 130, 177, .14), inset 0 1px 0 rgba(255,255,255,.8);
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--focus-ring), var(--shadow-inset-highlight-strong);
|
||||
}
|
||||
.email-address-editor.has-error {
|
||||
border-color: #c96b63;
|
||||
box-shadow: 0 0 0 3px rgba(201, 107, 99, .13);
|
||||
border-color: var(--danger-border);
|
||||
box-shadow: var(--danger-focus-ring-soft);
|
||||
}
|
||||
.email-address-editor .email-chip-list {
|
||||
display: flex;
|
||||
@@ -1402,10 +1428,10 @@
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #c7c2b8;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(#fff, #f0efec);
|
||||
color: #4f4b46;
|
||||
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
|
||||
color: var(--control-text);
|
||||
cursor: pointer;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
@@ -1413,8 +1439,8 @@
|
||||
padding: 0;
|
||||
}
|
||||
.email-address-plus:hover {
|
||||
border-color: #aaa49a;
|
||||
background: linear-gradient(#fff, #e9e7e3);
|
||||
border-color: var(--control-border-hover);
|
||||
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-hover));
|
||||
}
|
||||
.email-address-popover {
|
||||
position: absolute;
|
||||
@@ -1424,9 +1450,9 @@
|
||||
width: min(360px, calc(100vw - 64px));
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line-dark);
|
||||
border: var(--border-line-dark);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-popover);
|
||||
padding: 14px;
|
||||
}
|
||||
@@ -1449,10 +1475,10 @@
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-width: 560px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.08);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 24px var(--hover-tint);
|
||||
padding: 6px;
|
||||
}
|
||||
.email-address-suggestions button {
|
||||
@@ -1521,10 +1547,10 @@
|
||||
flex: 0 0 auto;
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
border: 1px solid #aeb4bb;
|
||||
border: 1px solid var(--toggle-track-border);
|
||||
border-radius: 999px;
|
||||
background: #cfd4da;
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,.12);
|
||||
background: var(--toggle-track-bg);
|
||||
box-shadow: var(--shadow-control-inset-strong);
|
||||
transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease;
|
||||
}
|
||||
.toggle-switch-thumb {
|
||||
@@ -1534,19 +1560,19 @@
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.22);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-thumb);
|
||||
transition: transform .16s ease;
|
||||
}
|
||||
.toggle-switch-input:checked + .toggle-switch-track {
|
||||
border-color: #0d6efd;
|
||||
background: #0d6efd;
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
}
|
||||
.toggle-switch-input:checked + .toggle-switch-track .toggle-switch-thumb {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
.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 {
|
||||
filter: grayscale(.15);
|
||||
@@ -1604,7 +1630,7 @@
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
color: #686560;
|
||||
color: var(--text-subtle);
|
||||
background: var(--line-dark);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
@@ -1627,7 +1653,7 @@
|
||||
.policy-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1646,7 +1672,7 @@
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
}
|
||||
.policy-row:first-child {
|
||||
border-top: 0;
|
||||
@@ -1712,7 +1738,7 @@
|
||||
width: max-content;
|
||||
max-width: min(320px, calc(100vw - 48px));
|
||||
transform: translate(-50%, 3px);
|
||||
border: 1px solid var(--line-dark);
|
||||
border: var(--border-line-dark);
|
||||
border-radius: 7px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-popover);
|
||||
@@ -1734,8 +1760,8 @@
|
||||
top: 100%;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-right: 1px solid var(--line-dark);
|
||||
border-bottom: 1px solid var(--line-dark);
|
||||
border-right: var(--border-line-dark);
|
||||
border-bottom: var(--border-line-dark);
|
||||
background: var(--surface);
|
||||
transform: translate(-50%, -5px) rotate(45deg);
|
||||
}
|
||||
@@ -1767,8 +1793,8 @@
|
||||
}
|
||||
|
||||
.field-input-missing {
|
||||
border-color: #b42318 !important;
|
||||
box-shadow: 0 0 0 3px rgba(180, 35, 24, .14) !important;
|
||||
border-color: var(--danger-border-deep) !important;
|
||||
box-shadow: var(--danger-focus-ring) !important;
|
||||
}
|
||||
|
||||
.disabled-action-tooltip {
|
||||
@@ -1776,19 +1802,27 @@
|
||||
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=""]):focus-within::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 10px);
|
||||
z-index: 80;
|
||||
z-index: 20000;
|
||||
width: min(280px, 72vw);
|
||||
padding: 9px 11px;
|
||||
border: 1px solid rgba(180, 35, 24, .24);
|
||||
border: 1px solid var(--border-danger-soft);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
color: #7a241c;
|
||||
background: #fff7f5;
|
||||
box-shadow: var(--shadow-popover, 0 16px 32px rgba(15, 23, 42, .18));
|
||||
color: var(--danger-text-tooltip);
|
||||
background: var(--danger-muted-bg);
|
||||
box-shadow: var(--shadow-popover);
|
||||
content: attr(data-tooltip);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -1819,7 +1853,7 @@
|
||||
min-height: 120px;
|
||||
padding: 1.25rem;
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
background: rgba(255, 255, 255, 0.00);
|
||||
background: var(--transparent-surface);
|
||||
backdrop-filter: blur(1.5px);
|
||||
margin: -10px;
|
||||
}
|
||||
@@ -1829,11 +1863,11 @@
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
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;
|
||||
color: var(--text, #172033);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: var(--shadow-soft, 0 10px 28px rgba(15, 23, 42, 0.12));
|
||||
color: var(--text);
|
||||
background: var(--panel-glass-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1853,21 +1887,21 @@
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid #c9c3b9;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(#ffffff, #f1efeb);
|
||||
color: #4f4a43;
|
||||
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
|
||||
color: var(--control-text);
|
||||
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;
|
||||
}
|
||||
.card-collapse-toggle:hover {
|
||||
border-color: #aaa299;
|
||||
background: linear-gradient(#ffffff, #e8e5df);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
|
||||
border-color: var(--control-border-pressed);
|
||||
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-pressed));
|
||||
box-shadow: var(--shadow-control-hover);
|
||||
}
|
||||
.card-collapse-toggle:focus-visible {
|
||||
outline: 3px solid rgba(82, 130, 177, .22);
|
||||
outline: 3px solid var(--focus-ring-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.card-collapse-toggle svg {
|
||||
@@ -1907,7 +1941,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
|
||||
box-shadow: var(--shadow-dismissible);
|
||||
}
|
||||
|
||||
.alert-dismissible .alert-message {
|
||||
@@ -1932,9 +1966,9 @@
|
||||
top: auto;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border: 1px solid rgba(15, 23, 42, 0.14);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.2);
|
||||
box-shadow: var(--shadow-floating);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -1953,7 +1987,7 @@
|
||||
.alert-dismiss:hover,
|
||||
.alert-dismiss:focus-visible {
|
||||
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:focus-within,
|
||||
.explorer-tree-node-wrap.is-active {
|
||||
background: rgba(13, 110, 253, .08);
|
||||
background: var(--primary-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -2029,14 +2063,14 @@
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
border-right: var(--border-line);
|
||||
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
|
||||
}
|
||||
|
||||
.file-tree-heading {
|
||||
flex: 0 0 auto;
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
@@ -2054,7 +2088,7 @@
|
||||
.file-tree-space + .file-tree-space {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
}
|
||||
|
||||
.file-tree-node-wrap {
|
||||
@@ -2143,8 +2177,8 @@
|
||||
}
|
||||
|
||||
.file-tree-node-wrap.is-selected .file-tree-select {
|
||||
background: #0d6efd;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.file-tree-children {
|
||||
@@ -2159,10 +2193,10 @@
|
||||
justify-content: start;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid #c9c3b9;
|
||||
border: var(--border-line-dark);
|
||||
border-radius: 8px;
|
||||
background: #c9c3b9;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
||||
background: var(--line-dark);
|
||||
box-shadow: var(--shadow-control);
|
||||
}
|
||||
|
||||
.segmented-control-size-content {
|
||||
@@ -2186,18 +2220,18 @@
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(#ffffff, #f1efeb);
|
||||
color: #4f4a43;
|
||||
background: linear-gradient(var(--surface), var(--panel));
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
||||
box-shadow: var(--shadow-control);
|
||||
transition: background .18s ease, box-shadow .18s ease, color .18s ease;
|
||||
}
|
||||
|
||||
.segmented-control-option + .segmented-control-option {
|
||||
border-left: 1px solid #c9c3b9;
|
||||
border-left: var(--border-line-dark);
|
||||
}
|
||||
|
||||
.segmented-control-option:first-child {
|
||||
@@ -2210,13 +2244,13 @@
|
||||
|
||||
.segmented-control-option:hover,
|
||||
.segmented-control-option:focus-visible {
|
||||
background: linear-gradient(#ffffff, #e8e5df);
|
||||
color: #4f4a43;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
|
||||
background: linear-gradient(var(--surface), var(--bar));
|
||||
color: var(--text-strong);
|
||||
box-shadow: var(--shadow-control-hover);
|
||||
}
|
||||
|
||||
.segmented-control-option:focus-visible {
|
||||
outline: 3px solid rgba(82, 130, 177, .22);
|
||||
outline: 3px solid var(--focus-ring-color);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
@@ -2224,7 +2258,7 @@
|
||||
.segmented-control-option.active {
|
||||
background: var(--line);
|
||||
color: var(--text-strong);
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.12), inset 0 -1px 0 rgba(255,255,255,.45);
|
||||
box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .12), var(--shadow-inset-highlight);
|
||||
}
|
||||
|
||||
.segmented-control-option.is-active:hover,
|
||||
@@ -2232,7 +2266,7 @@
|
||||
.segmented-control-option.active:hover,
|
||||
.segmented-control-option.active:focus-visible {
|
||||
background: var(--line);
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.14), inset 0 -1px 0 rgba(255,255,255,.45);
|
||||
box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .14), var(--shadow-inset-highlight);
|
||||
}
|
||||
|
||||
.segmented-control-option:disabled {
|
||||
@@ -2240,6 +2274,84 @@
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -2327,7 +2439,7 @@
|
||||
max-height: 420px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text);
|
||||
@@ -2342,7 +2454,7 @@
|
||||
|
||||
.message-display-html-frame {
|
||||
height: 420px;
|
||||
background: #fff;
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.message-display-attachments {
|
||||
@@ -2372,7 +2484,7 @@
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -2413,7 +2525,7 @@
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
@@ -2514,7 +2626,7 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.mail-server-toggle-row {
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -2544,7 +2656,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 99px;
|
||||
background: var(--panel-soft);
|
||||
padding: 3px 9px;
|
||||
@@ -2559,6 +2671,7 @@
|
||||
.form-grid.compact.mail-server-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.credential-panel-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-segmented-control { width: 100%; }
|
||||
@@ -2588,20 +2701,20 @@
|
||||
.file-drop-zone:hover,
|
||||
.file-drop-zone:focus-visible,
|
||||
.file-drop-zone.is-active {
|
||||
border-color: #0d6efd;
|
||||
background: rgba(13, 110, 253, .08);
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
.file-drop-zone:focus-visible {
|
||||
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"] {
|
||||
cursor: not-allowed;
|
||||
opacity: .65;
|
||||
}
|
||||
.file-drop-zone.is-busy {
|
||||
border-color: #0d6efd;
|
||||
background: rgba(13, 110, 253, .08);
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-soft);
|
||||
cursor: progress;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -2613,7 +2726,7 @@
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
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);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
@@ -2625,14 +2738,14 @@
|
||||
inset: 6px;
|
||||
border-radius: 50%;
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
@keyframes file-drop-progress-spin {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(37, 40, 42, 0.38);
|
||||
background: var(--overlay-backdrop);
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
@@ -15,10 +15,10 @@
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.26);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-modal);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -27,7 +27,7 @@
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
@@ -58,7 +58,7 @@
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background: rgba(15, 23, 42, 0.36);
|
||||
background: var(--dialog-backdrop);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
@@ -79,11 +79,11 @@
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
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);
|
||||
background: var(--surface, #fff);
|
||||
color: var(--text, #172033);
|
||||
box-shadow: var(--shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.25));
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-strong);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
@@ -94,13 +94,13 @@
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel, #f7f6f4);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
margin: 0;
|
||||
color: var(--text-strong, #111827);
|
||||
color: var(--text-strong);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--muted, #5f6b7a);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
@@ -121,8 +121,8 @@
|
||||
|
||||
.dialog-close:hover,
|
||||
.dialog-close:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.07);
|
||||
color: var(--text-strong, #111827);
|
||||
background: var(--hover-tint-soft);
|
||||
color: var(--text-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
color: var(--text, #172033);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
@@ -145,8 +145,8 @@
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--panel, #f7f6f4);
|
||||
border-top: var(--border-line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
@@ -155,12 +155,12 @@
|
||||
|
||||
.confirm-dialog > .dialog-header,
|
||||
.confirm-dialog > .dialog-footer {
|
||||
background: var(--panel, #fff);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.confirm-dialog p {
|
||||
margin: 0;
|
||||
color: var(--muted, #5f6b7a);
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -174,14 +174,14 @@
|
||||
|
||||
.admin-dialog > .dialog-header {
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.admin-dialog > .dialog-footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
background: var(--panel);
|
||||
box-shadow: 0 -8px 24px rgba(15, 23, 42, .06);
|
||||
box-shadow: var(--shadow-footer);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.form-grid { display: grid; gap: 18px; }
|
||||
.form-grid.compact { grid-template-columns: 1fr 1fr; }
|
||||
.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); }
|
||||
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; }
|
||||
.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"]),
|
||||
@@ -14,7 +14,7 @@ textarea[readonly] {
|
||||
border-color: var(--control-disabled-border);
|
||||
background: var(--control-disabled-bg);
|
||||
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;
|
||||
}
|
||||
input:disabled:not([type="checkbox"]):not([type="radio"]),
|
||||
@@ -33,8 +33,8 @@ textarea[readonly]::placeholder {
|
||||
color: var(--control-disabled-placeholder);
|
||||
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-primary { background: var(--green); border-color: #5aa99b; color: #fff; }
|
||||
.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: var(--success-border); color: var(--on-accent); }
|
||||
.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; }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: 58px 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; }
|
||||
.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; }
|
||||
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: auto 1fr; overflow: hidden; }
|
||||
.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; }
|
||||
.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;
|
||||
top: 9px;
|
||||
left: 9px;
|
||||
@@ -10,18 +12,32 @@
|
||||
content: "";
|
||||
background-color: var(--rail-bg);
|
||||
}
|
||||
.icon-nav { width: 100%; display: flex; flex-direction: column; }
|
||||
.icon-nav-item { height: 52px; display: grid; place-items: center; color: #a7a49f; border-left: 3px solid transparent; text-decoration: none; }
|
||||
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: #fff; border-left-color: var(--accent); }
|
||||
.icon-rail-toggle { border: 0; background: transparent; font: inherit; cursor: pointer; padding: 0; }
|
||||
.icon-rail-toggle:hover,
|
||||
.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); }
|
||||
.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; }
|
||||
.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); }
|
||||
.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: 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); }
|
||||
.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); }
|
||||
.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:hover { background: #ffd170; color: #3e2800; }
|
||||
.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: var(--warning-bg-hover); color: var(--warning-text-hover); }
|
||||
.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 { min-width: 210px; }
|
||||
@@ -29,19 +45,19 @@
|
||||
.language-menu .dropdown-item svg { margin-left: auto; }
|
||||
.language-option-code { width: 34px; color: var(--muted); }
|
||||
.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; }
|
||||
.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; }
|
||||
.breadcrumbs { display: flex; gap: 6px; text-transform: uppercase; font-weight: 700; font-size: 13px; color: #615f5c; }
|
||||
.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: 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: var(--text-breadcrumb); }
|
||||
.crumb { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.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; }
|
||||
.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-title { font-size: 12px; font-weight: 800; color: #7f7b75; padding: 0 22px 14px; letter-spacing: .06em; }
|
||||
.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: var(--muted); padding: 0 22px 14px; letter-spacing: .06em; }
|
||||
.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:hover, .section-link.active { background: rgba(255,255,255,.35); color: #3e3e3f; }
|
||||
.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: var(--sidebar-hover-bg); color: var(--text-hover-strong); }
|
||||
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
|
||||
.section-link.subtle { font-size: 13px; }
|
||||
.workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; }
|
||||
@@ -64,49 +80,49 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 75;
|
||||
background: var(--bg, #f8f7f4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: 0 8px 18px rgba(65, 60, 52, .08);
|
||||
background: var(--bg);
|
||||
border-bottom: var(--border-line);
|
||||
box-shadow: var(--shadow-header);
|
||||
margin: -28px -34px 22px;
|
||||
padding: 18px 34px 16px;
|
||||
}
|
||||
.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; }
|
||||
.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); }
|
||||
.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: 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-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
|
||||
.card-body { padding: 22px 24px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
||||
.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-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
|
||||
.metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; }
|
||||
.metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; }
|
||||
.dashboard-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; align-items: start; }
|
||||
.wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; }
|
||||
.wizard-card { width: min(980px, 100%); background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; }
|
||||
.wizard-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-heading { display: flex; justify-content: space-between; margin-bottom: 18px; }
|
||||
.wizard-heading h1 { margin: 0; }
|
||||
.save-state { color: var(--green); font-size: 13px; font-weight: 700; }
|
||||
.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); }
|
||||
.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.active button { background: #fff; 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; }
|
||||
.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: var(--muted); cursor: pointer; }
|
||||
.step.active button { background: var(--surface); color: var(--text-strong); }
|
||||
.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 small { display: block; margin-top: 3px; color: var(--muted); }
|
||||
.step-intro h2 { margin: 0; color: var(--text-strong); }
|
||||
.step-intro p { margin-top: 6px; color: var(--muted); }
|
||||
.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.warning { background: #ffe1a3; } .alert.danger { background: #f3c5be; }
|
||||
.table-like { border: 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: 1px solid var(--line); }
|
||||
.alert.warning { background: var(--warning-bg); } .alert.danger { background: var(--danger-bg); }
|
||||
.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: var(--border-line); }
|
||||
.table-row-link:hover { background: var(--panel-soft); }
|
||||
.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) {
|
||||
.api-mini { display: none; }
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
@@ -119,29 +135,29 @@
|
||||
/* Layout additions: login and help dropdown */
|
||||
.tenant-selector.disabled { opacity: .86; cursor: default; }
|
||||
.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 hr { border: 0; border-top: 1px solid var(--line); margin: 8px 0; }
|
||||
.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: 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:hover { background: var(--panel-soft); color: var(--text-strong); }
|
||||
.dropdown-item small { margin-left: auto; color: var(--muted); }
|
||||
.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 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 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; }
|
||||
.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; }
|
||||
.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: 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; }
|
||||
|
||||
/* Campaign workspace polish */
|
||||
.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; }
|
||||
.below-grid { margin-top: 18px; }
|
||||
.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 dt { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; }
|
||||
.detail-list dd { margin: 0; min-width: 0; }
|
||||
@@ -152,7 +168,7 @@
|
||||
.next-action-warning { border-left-color: var(--amber); }
|
||||
.next-action-info { border-left-color: var(--blue); }
|
||||
.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 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; }
|
||||
@@ -168,23 +184,23 @@
|
||||
.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-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:hover { background: rgba(255,255,255,.35); color: var(--text-strong); }
|
||||
.docs-tree-toggle { border: 0; border-radius: 4px; background: transparent; color: var(--text-subtle); cursor: pointer; }
|
||||
.docs-tree-toggle:hover { background: var(--sidebar-hover-bg); color: var(--text-strong); }
|
||||
.docs-tree-toggle-placeholder { opacity: 0; }
|
||||
.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:hover, .docs-tree-page.is-active { background: rgba(255,255,255,.42); color: var(--text-strong); }
|
||||
.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: 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-empty { color: var(--muted); font-size: 12px; line-height: 1.35; padding: 8px 9px; }
|
||||
.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-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 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: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 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; }
|
||||
@@ -221,3 +237,6 @@
|
||||
.icon-rail-bottom .icon-nav-item {
|
||||
width: 100%;
|
||||
}
|
||||
.icon-rail-bottom .icon-rail-toggle {
|
||||
border-top: 1px solid var(--rail-bg-active);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Legacy mapping / rule tables used by early wizard surfaces. */
|
||||
.mapping-table {
|
||||
margin-top: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
.mapping-header {
|
||||
background: var(--bar);
|
||||
color: #6b6863;
|
||||
color: var(--text-label);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
@@ -21,16 +21,16 @@
|
||||
.mapping-header span,
|
||||
.mapping-row span {
|
||||
padding: 12px 14px;
|
||||
border-right: 1px solid var(--line);
|
||||
border-right: var(--border-line);
|
||||
}
|
||||
.mapping-row {
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-top: var(--border-line);
|
||||
}
|
||||
.attachment-rule-card {
|
||||
margin: 18px 0;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
padding: 18px;
|
||||
}
|
||||
@@ -48,11 +48,11 @@
|
||||
.app-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.app-table thead {
|
||||
background: var(--bar);
|
||||
color: #625f5a;
|
||||
color: var(--text-table-heading);
|
||||
font-size: 12px;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
@@ -60,7 +60,7 @@
|
||||
.app-table th {
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-right: 1px solid var(--line-dark);
|
||||
border-right: var(--border-line-dark);
|
||||
font-weight: 800;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
@@ -69,7 +69,7 @@
|
||||
.app-table td {
|
||||
min-height: 56px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.app-table tbody tr:hover { background: var(--panel-soft); }
|
||||
@@ -98,11 +98,11 @@
|
||||
table-layout: fixed;
|
||||
}
|
||||
.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 {
|
||||
background: var(--bar);
|
||||
border-right: 1px solid var(--line-dark);
|
||||
border-right: var(--border-line-dark);
|
||||
}
|
||||
.campaign-table th:nth-child(1),
|
||||
.campaign-table td:nth-child(1) { width: auto; min-width: 360px; }
|
||||
@@ -115,7 +115,7 @@
|
||||
.campaign-table th:nth-child(5),
|
||||
.campaign-table td:nth-child(5) { width: 105px; text-align: right; }
|
||||
.campaign-table td.updated-cell {
|
||||
color: #696660;
|
||||
color: var(--text-subtle);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
@@ -132,7 +132,7 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -156,7 +156,7 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
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 {
|
||||
@@ -244,8 +244,8 @@
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 11px 12px;
|
||||
border-right: 1px solid rgba(189, 184, 176, 0.32);
|
||||
border-bottom: 1px solid var(--border-soft, var(--line));
|
||||
border-right: var(--border-muted-line);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -279,8 +279,8 @@
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
background: var(--bar);
|
||||
border-bottom: 1px solid var(--line-dark);
|
||||
color: #625f5a;
|
||||
border-bottom: var(--border-line-dark);
|
||||
color: var(--text-table-heading);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
@@ -293,7 +293,7 @@
|
||||
}
|
||||
|
||||
.data-grid-body-cell.data-grid-row-odd {
|
||||
background: #fbfaf8;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.data-grid-body-cell.data-grid-row-even {
|
||||
@@ -359,7 +359,7 @@
|
||||
.data-grid-filter-trigger {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(98, 95, 90, .68);
|
||||
color: var(--text-table-heading-muted);
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -384,20 +384,20 @@
|
||||
.data-grid-filter-trigger:focus-visible,
|
||||
.data-grid-filter-trigger.is-open {
|
||||
color: var(--text-strong);
|
||||
background: rgba(255, 255, 255, .45);
|
||||
background: var(--panel-glass-muted);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.data-grid-filter-trigger.has-filter {
|
||||
color: var(--text-strong);
|
||||
background: rgba(255, 255, 255, .62);
|
||||
box-shadow: inset 0 0 0 1px rgba(98, 95, 90, .24);
|
||||
background: var(--panel-glass);
|
||||
box-shadow: inset 0 0 0 1px var(--text-table-heading-muted-border);
|
||||
}
|
||||
|
||||
.data-grid-filter-popover {
|
||||
position: fixed;
|
||||
z-index: 12000;
|
||||
border: 1px solid var(--line-dark);
|
||||
border: var(--border-line-dark);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-menu);
|
||||
@@ -458,9 +458,9 @@
|
||||
.data-grid-filter-field input,
|
||||
.data-grid-filter-field select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
@@ -496,7 +496,7 @@
|
||||
padding: 20px;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.data-grid-cell.is-sticky-start,
|
||||
@@ -507,19 +507,19 @@
|
||||
}
|
||||
|
||||
.data-grid-cell.is-sticky-start {
|
||||
border-right: 1px solid var(--line-dark);
|
||||
box-shadow: 8px 0 14px -14px rgba(45, 43, 40, .6);
|
||||
border-right: var(--border-line-dark);
|
||||
box-shadow: var(--shadow-sticky-start);
|
||||
}
|
||||
|
||||
.data-grid-cell.is-sticky-end {
|
||||
border-left: 1px solid var(--line-dark);
|
||||
box-shadow: -8px 0 14px -14px rgba(45, 43, 40, .6);
|
||||
border-left: var(--border-line-dark);
|
||||
box-shadow: var(--shadow-sticky-end);
|
||||
}
|
||||
|
||||
.data-grid-header-cell.is-sticky-start,
|
||||
.data-grid-header-cell.is-sticky-end {
|
||||
z-index: 5;
|
||||
border-bottom: 1px solid var(--line-dark);
|
||||
border-bottom: var(--border-line-dark);
|
||||
background: var(--bar);
|
||||
}
|
||||
|
||||
@@ -534,9 +534,9 @@
|
||||
.data-grid-list-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
padding: 6px 28px 6px 8px;
|
||||
@@ -580,9 +580,9 @@
|
||||
.data-grid-list-filter-options {
|
||||
max-height: 230px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.data-grid-list-filter-row {
|
||||
@@ -591,7 +591,7 @@
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.data-grid-list-filter-row:last-child {
|
||||
@@ -645,9 +645,9 @@
|
||||
|
||||
.data-grid-list-option-add input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
padding: 8px 10px;
|
||||
@@ -694,7 +694,7 @@
|
||||
.data-grid-pagination {
|
||||
min-height: 52px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-soft, var(--line));
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -731,7 +731,7 @@
|
||||
.data-grid-page-controls button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-xs, 5px);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
@@ -742,7 +742,7 @@
|
||||
.data-grid-page-controls button:hover:not(:disabled),
|
||||
.data-grid-page-controls button:focus-visible {
|
||||
border-color: var(--line-dark);
|
||||
background: var(--surface-strong, #f5f3ef);
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.data-grid-page-controls button:disabled {
|
||||
@@ -765,7 +765,7 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -779,7 +779,7 @@
|
||||
|
||||
.connection-tree-header {
|
||||
min-height: 40px;
|
||||
border-bottom: 1px solid var(--line-dark);
|
||||
border-bottom: var(--border-line-dark);
|
||||
background: var(--bar);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
@@ -789,7 +789,7 @@
|
||||
|
||||
.connection-tree-row {
|
||||
min-height: 58px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
@@ -806,7 +806,7 @@
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-right: 1px solid var(--line);
|
||||
border-right: var(--border-line);
|
||||
}
|
||||
|
||||
.connection-tree-cell:last-child {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--white: #ffffff;
|
||||
--black: #000000;
|
||||
--transparent: transparent;
|
||||
|
||||
--rail-bg: #25282a;
|
||||
--rail-bg-active: #1c1e20;
|
||||
--rail-text: #c7c6c0;
|
||||
--rail-text-muted: #a7a49f;
|
||||
--accent: #ef6b3a;
|
||||
--accent-rgb: 239, 107, 58;
|
||||
--accent-soft: rgba(239, 107, 58, .35);
|
||||
|
||||
--bg: #e9e7e4;
|
||||
@@ -11,48 +20,242 @@
|
||||
--panel-header: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--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-dark: #bdb8b0;
|
||||
--line-subtle: var(--line);
|
||||
--line-strong: var(--line-dark);
|
||||
--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-strong: #303135;
|
||||
--muted: #8a8781;
|
||||
--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-border: #c2bcb3;
|
||||
--control-disabled-text: #706b63;
|
||||
--control-disabled-placeholder: #969087;
|
||||
--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;
|
||||
--amber: #f5c56c;
|
||||
--red: #e06a5f;
|
||||
--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-soft: #d6eee9;
|
||||
--success-border: #5aa99b;
|
||||
--success-border-soft: #78b7a9;
|
||||
--success-line: var(--success-border);
|
||||
--success: #157347;
|
||||
--success-text: #315f55;
|
||||
--success-text-strong: #34796d;
|
||||
--info-bg: #dce9f3;
|
||||
--info-soft: #d8e8f4;
|
||||
--info-muted-bg: #e8eef5;
|
||||
--info-muted-border: #c8d6e3;
|
||||
--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-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-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-menu: 0 16px 40px rgba(0,0,0,.18);
|
||||
--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-xs: 5px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: 12px;
|
||||
--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;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--rail-bg: #171819;
|
||||
--rail-bg-active: #0f1011;
|
||||
--rail-text: #dfddd7;
|
||||
--rail-text-muted: #aaa79d;
|
||||
--bg: #202120;
|
||||
--bar: #34342f;
|
||||
--panel: #2b2c2a;
|
||||
@@ -60,21 +263,120 @@
|
||||
--panel-header: #30312e;
|
||||
--surface: #262724;
|
||||
--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-dark: #5b5d54;
|
||||
--border: var(--line);
|
||||
--border-soft: rgba(255,255,255,.10);
|
||||
--border-muted: rgba(91, 93, 84, .55);
|
||||
--text: #dfddd7;
|
||||
--text-strong: #f6f4ed;
|
||||
--muted: #aaa79d;
|
||||
--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-border: #5f6058;
|
||||
--control-disabled-text: #b8b4aa;
|
||||
--control-disabled-placeholder: #918d83;
|
||||
--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-menu: 0 18px 44px rgba(0,0,0,.42);
|
||||
--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 *,
|
||||
|
||||
@@ -110,6 +110,59 @@ export type AuthInfo = {
|
||||
available_languages?: Array<{ code: string; label: string; native_label?: string | null }>;
|
||||
enabled_language_codes?: 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 & {
|
||||
@@ -189,7 +242,7 @@ export type PlatformNavItem = {
|
||||
export type PlatformRouteContext = {
|
||||
settings: ApiSettings;
|
||||
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;
|
||||
@@ -214,6 +267,29 @@ export type AdminSectionsUiCapability = {
|
||||
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 = {
|
||||
path: string;
|
||||
anyOf?: string[];
|
||||
@@ -378,6 +454,7 @@ export type MailServerProfile = {
|
||||
|
||||
export type MailCredentialPolicy = {
|
||||
inherit?: boolean | null;
|
||||
allow_override?: boolean | null;
|
||||
};
|
||||
|
||||
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
|
||||
@@ -391,6 +468,7 @@ export type MailProfilePolicy = {
|
||||
imap_credentials?: MailCredentialPolicy | null;
|
||||
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
|
||||
blacklist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
|
||||
allow_lower_level_limits?: Partial<Record<string, boolean>> | null;
|
||||
};
|
||||
|
||||
export type MailPolicyValidationInput = {
|
||||
|
||||
@@ -13,9 +13,12 @@ function assertDeepEqual(actual: unknown, expected: unknown, message = "values s
|
||||
}
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import CredentialPanel from "../src/components/CredentialPanel";
|
||||
import PasswordField from "../src/components/PasswordField";
|
||||
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
|
||||
import MailServerSettingsPanel from "../src/components/mail/MailServerSettingsPanel";
|
||||
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
|
||||
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("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(
|
||||
<MailServerSettingsPanel
|
||||
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.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(
|
||||
<MailServerSettingsPanel
|
||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
},
|
||||
"include": [
|
||||
"tests/mail-components.test.tsx",
|
||||
"src/components/CredentialPanel.tsx",
|
||||
"src/components/email/EmailAddressInput.tsx",
|
||||
"src/components/PasswordField.tsx",
|
||||
"src/components/MessageDisplayPanel.tsx",
|
||||
"src/components/mail/MailServerSettingsPanel.tsx",
|
||||
|
||||
@@ -13,6 +13,7 @@ const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`;
|
||||
const defaultWebModulePackages = [
|
||||
"@govoplan/access-webui",
|
||||
"@govoplan/admin-webui",
|
||||
"@govoplan/addresses-webui",
|
||||
"@govoplan/audit-webui",
|
||||
"@govoplan/calendar-webui",
|
||||
"@govoplan/campaign-webui",
|
||||
@@ -21,6 +22,7 @@ const defaultWebModulePackages = [
|
||||
"@govoplan/files-webui",
|
||||
"@govoplan/idm-webui",
|
||||
"@govoplan/mail-webui",
|
||||
"@govoplan/notifications-webui",
|
||||
"@govoplan/organizations-webui",
|
||||
"@govoplan/ops-webui",
|
||||
"@govoplan/policy-webui",
|
||||
@@ -95,6 +97,7 @@ export default defineConfig({
|
||||
fileURLToPath(new URL('.', 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-addresses/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-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-idm/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-campaign/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: {
|
||||
|
||||
Reference in New Issue
Block a user