Compare commits
8
Commits
7eef52776c
...
7184b6cdd6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7184b6cdd6 | ||
|
|
ea8c600dce | ||
|
|
1839693575 | ||
|
|
183bf7aef0 | ||
|
|
37a5dfb182 | ||
|
|
844f934379 | ||
|
|
a98475f7bc | ||
|
|
1153c9dd36 |
@@ -138,11 +138,13 @@ dist
|
|||||||
|
|
||||||
# Local WebUI test/build scratch directories
|
# Local WebUI test/build scratch directories
|
||||||
.component-test-build/
|
.component-test-build/
|
||||||
|
.file-drop-test-build/
|
||||||
.module-test-build/
|
.module-test-build/
|
||||||
.policy-test-build/
|
.policy-test-build/
|
||||||
.template-preview-test-build/
|
.template-preview-test-build/
|
||||||
.import-test-build/
|
.import-test-build/
|
||||||
webui/.component-test-build/
|
webui/.component-test-build/
|
||||||
|
webui/.file-drop-test-build/
|
||||||
webui/.module-test-build/
|
webui/.module-test-build/
|
||||||
webui/.policy-test-build/
|
webui/.policy-test-build/
|
||||||
webui/.template-preview-test-build/
|
webui/.template-preview-test-build/
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ class AuthGroupsResponse(BaseModel):
|
|||||||
|
|
||||||
class LoginResponse(BaseModel):
|
class LoginResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer" # noqa: S105 - OAuth token type, not a credential.
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
user: UserInfo
|
user: UserInfo
|
||||||
# Backwards-compatible alias for the active tenant.
|
# Backwards-compatible alias for the active tenant.
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
"""Core auth dependency facade.
|
"""Core auth dependency facade.
|
||||||
|
|
||||||
Routers depend on this module instead of a concrete access-provider package.
|
Routers depend on this module instead of a concrete access-provider package.
|
||||||
@@ -7,6 +5,8 @@ The active auth module provides the request principal through the platform
|
|||||||
capability registry.
|
capability registry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request, status
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from collections.abc import Mapping
|
from typing import Literal
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from govoplan_core.security.permissions import scopes_grant
|
from govoplan_core.security.permissions import scopes_grant
|
||||||
from govoplan_core.security.redaction import contains_plain_secret
|
from govoplan_core.security.redaction import contains_plain_secret
|
||||||
@@ -22,7 +21,7 @@ class ConfigurationFieldSafety:
|
|||||||
storage: str
|
storage: str
|
||||||
ui_managed: bool
|
ui_managed: bool
|
||||||
risk: ConfigurationRisk
|
risk: ConfigurationRisk
|
||||||
secret_handling: SecretHandling = "none"
|
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
|
||||||
required_scopes: tuple[str, ...] = ()
|
required_scopes: tuple[str, ...] = ()
|
||||||
dry_run_required: bool = False
|
dry_run_required: bool = False
|
||||||
validation_required: bool = True
|
validation_required: bool = True
|
||||||
@@ -69,7 +68,7 @@ class ConfigurationChangeSafetyPlan:
|
|||||||
maintenance_required: bool = False
|
maintenance_required: bool = False
|
||||||
maintenance_satisfied: bool = False
|
maintenance_satisfied: bool = False
|
||||||
rollback_history_required: bool = False
|
rollback_history_required: bool = False
|
||||||
secret_handling: SecretHandling = "none"
|
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
|
||||||
audit_event: str | None = None
|
audit_event: str | None = None
|
||||||
policy_explanation: str | None = None
|
policy_explanation: str | None = None
|
||||||
blockers: tuple[str, ...] = ()
|
blockers: tuple[str, ...] = ()
|
||||||
@@ -240,7 +239,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="module_settings",
|
storage="module_settings",
|
||||||
ui_managed=True,
|
ui_managed=True,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="reference_only",
|
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
required_scopes=("mail_servers:manage_credentials",),
|
required_scopes=("mail_servers:manage_credentials",),
|
||||||
validation_required=True,
|
validation_required=True,
|
||||||
audit_event="mail_server_profile.credential_updated",
|
audit_event="mail_server_profile.credential_updated",
|
||||||
@@ -256,7 +255,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="module_settings",
|
storage="module_settings",
|
||||||
ui_managed=True,
|
ui_managed=True,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="reference_only",
|
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
required_scopes=("files:file:admin",),
|
required_scopes=("files:file:admin",),
|
||||||
dry_run_required=True,
|
dry_run_required=True,
|
||||||
policy_explanation_required=True,
|
policy_explanation_required=True,
|
||||||
@@ -273,7 +272,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="destructive",
|
risk="destructive",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
maintenance_required=True,
|
maintenance_required=True,
|
||||||
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
|
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
|
||||||
),
|
),
|
||||||
@@ -285,7 +284,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="destructive",
|
risk="destructive",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
maintenance_required=True,
|
maintenance_required=True,
|
||||||
two_person_approval_required=True,
|
two_person_approval_required=True,
|
||||||
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
|
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
|
||||||
@@ -298,7 +297,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
|
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
|
||||||
),
|
),
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
@@ -309,7 +308,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
notes="Configuration package trust roots are deployment-managed.",
|
notes="Configuration package trust roots are deployment-managed.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -414,9 +413,9 @@ def _configuration_change_safety_state(
|
|||||||
approval_satisfied = not approval_required or approval_count >= 2
|
approval_satisfied = not approval_required or approval_count >= 2
|
||||||
if approval_required and not approval_satisfied:
|
if approval_required and not approval_satisfied:
|
||||||
blockers.append("two_person_approval_required")
|
blockers.append("two_person_approval_required")
|
||||||
if field.secret_handling == "reference_only" and _contains_plain_secret(value):
|
if field.secret_handling == "reference_only" and _contains_plain_secret(value): # noqa: S105 # nosec B105 - policy vocabulary.
|
||||||
blockers.append("secret_reference_required")
|
blockers.append("secret_reference_required")
|
||||||
if field.secret_handling == "env_only" and value is not None:
|
if field.secret_handling == "env_only" and value is not None: # noqa: S105 # nosec B105 - policy vocabulary.
|
||||||
blockers.append("env_only_secret")
|
blockers.append("env_only_secret")
|
||||||
if field.rollback_history_required:
|
if field.rollback_history_required:
|
||||||
warnings.append("rollback_history_required")
|
warnings.append("rollback_history_required")
|
||||||
@@ -466,7 +465,7 @@ def _policy_explanation(field: ConfigurationFieldSafety) -> str:
|
|||||||
parts.append("requires two-person approval")
|
parts.append("requires two-person approval")
|
||||||
if field.maintenance_required:
|
if field.maintenance_required:
|
||||||
parts.append("requires maintenance mode")
|
parts.append("requires maintenance mode")
|
||||||
if field.secret_handling != "none":
|
if field.secret_handling != "none": # noqa: S105 # nosec B105 - policy vocabulary.
|
||||||
parts.append(f"uses {field.secret_handling} secret handling")
|
parts.append(f"uses {field.secret_handling} secret handling")
|
||||||
return "; ".join(parts) + "."
|
return "; ".join(parts) + "."
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import re
|
|||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import stat
|
||||||
|
import subprocess # nosec B404 - installer commands are structured and policy-validated before execution.
|
||||||
import sys
|
import sys
|
||||||
import tomllib
|
import tomllib
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
@@ -579,7 +580,7 @@ def _prepare_module_install_run(
|
|||||||
) -> _ModuleInstallRunState:
|
) -> _ModuleInstallRunState:
|
||||||
run_id = _run_id()
|
run_id = _run_id()
|
||||||
run_dir = effective_runtime_dir / "runs" / run_id
|
run_dir = effective_runtime_dir / "runs" / run_id
|
||||||
run_dir.mkdir(parents=True, exist_ok=False)
|
_create_private_installer_run_dir(run_dir)
|
||||||
commands = structured_install_commands(
|
commands = structured_install_commands(
|
||||||
plan,
|
plan,
|
||||||
webui_root=webui_root,
|
webui_root=webui_root,
|
||||||
@@ -2445,8 +2446,8 @@ def _migration_provider_modules(
|
|||||||
target_metadata: Mapping[str, Mapping[str, object]],
|
target_metadata: Mapping[str, Mapping[str, object]],
|
||||||
) -> dict[str, tuple[tuple[str, str], ...]]:
|
) -> dict[str, tuple[tuple[str, str], ...]]:
|
||||||
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||||
for module_id, metadata in target_metadata.items():
|
for module_id, module_metadata in target_metadata.items():
|
||||||
for provided in metadata.get("provides_interfaces", ()):
|
for provided in module_metadata.get("provides_interfaces", ()):
|
||||||
if not isinstance(provided, Mapping):
|
if not isinstance(provided, Mapping):
|
||||||
continue
|
continue
|
||||||
name = provided.get("name") if isinstance(provided.get("name"), str) else None
|
name = provided.get("name") if isinstance(provided.get("name"), str) else None
|
||||||
@@ -3828,14 +3829,47 @@ def _restore_external_database_snapshot(
|
|||||||
def _write_installer_secret(path: Path, value: str | None) -> str | None:
|
def _write_installer_secret(path: Path, value: str | None) -> str | None:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
path.write_text(value, encoding="utf-8")
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
flags |= getattr(os, "O_CLOEXEC", 0)
|
||||||
|
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||||
try:
|
try:
|
||||||
path.chmod(0o600)
|
descriptor = os.open(path, flags, 0o600)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.debug("Could not restrict installer secret file permissions for %s: %s", path, exc, exc_info=True)
|
raise ModuleInstallerError(f"Could not create private installer secret file: {path.name}") from exc
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(value)
|
||||||
|
except OSError as exc:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
raise ModuleInstallerError(f"Could not write private installer secret file: {path.name}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
mode = stat.S_IMODE(path.stat().st_mode)
|
||||||
|
except OSError as exc:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
raise ModuleInstallerError(f"Could not verify private installer secret file: {path.name}") from exc
|
||||||
|
if mode != 0o600:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
raise ModuleInstallerError(f"Installer secret file permissions are not private: {path.name}")
|
||||||
return path.name
|
return path.name
|
||||||
|
|
||||||
|
|
||||||
|
def _create_private_installer_run_dir(path: Path) -> None:
|
||||||
|
path.mkdir(parents=True, mode=0o700, exist_ok=False)
|
||||||
|
try:
|
||||||
|
# mkdir's requested mode is still reduced by the process umask. Set the
|
||||||
|
# exact owner-only mode so the directory remains usable with a strict
|
||||||
|
# deployment umask while never retaining group/other access.
|
||||||
|
path.chmod(0o700)
|
||||||
|
mode = stat.S_IMODE(path.stat().st_mode)
|
||||||
|
except OSError as exc:
|
||||||
|
path.rmdir()
|
||||||
|
raise ModuleInstallerError("Could not secure the installer run directory") from exc
|
||||||
|
if mode != 0o700:
|
||||||
|
path.rmdir()
|
||||||
|
raise ModuleInstallerError("Installer run directory permissions are not exactly owner-only")
|
||||||
|
|
||||||
|
|
||||||
def _database_url_from_external_snapshot(run_dir: Path, raw: Mapping[str, object]) -> str | None:
|
def _database_url_from_external_snapshot(run_dir: Path, raw: Mapping[str, object]) -> str | None:
|
||||||
secret_name = raw.get("database_url_secret")
|
secret_name = raw.get("database_url_secret")
|
||||||
if isinstance(secret_name, str) and secret_name:
|
if isinstance(secret_name, str) and secret_name:
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ def installer_notification_body(event_kind: str, request: Mapping[str, object])
|
|||||||
run_id = _result_run_id(request)
|
run_id = _result_run_id(request)
|
||||||
if run_id:
|
if run_id:
|
||||||
return ". Run: ".join((sentence, run_id))
|
return ". Run: ".join((sentence, run_id))
|
||||||
return sentence + "."
|
# This helper returns plain notification text, not an HTTP/HTML response.
|
||||||
|
return sentence + "." # nosemgrep: python.flask.security.audit.directly-returned-format-string.directly-returned-format-string
|
||||||
|
|
||||||
|
|
||||||
def installer_notification_priority(status: str) -> int:
|
def installer_notification_priority(status: str) -> int:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from datetime import UTC, datetime
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ def bootstrap_dev_data(
|
|||||||
api_key_secret: str | None = None,
|
api_key_secret: str | None = None,
|
||||||
tenant_slug: str = "default",
|
tenant_slug: str = "default",
|
||||||
user_email: str = "admin@example.local",
|
user_email: str = "admin@example.local",
|
||||||
user_password: str = "dev-admin",
|
user_password: str = "dev-admin", # noqa: S107 - development bootstrap only.
|
||||||
) -> BootstrapResult:
|
) -> BootstrapResult:
|
||||||
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
|
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
|
||||||
if tenant is None:
|
if tenant is None:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -215,7 +215,10 @@ def _registered_module_registry(
|
|||||||
server_config = get_server_config()
|
server_config = get_server_config()
|
||||||
active_database_url = database_url or settings.database_url
|
active_database_url = database_url or settings.database_url
|
||||||
if active_database_url:
|
if active_database_url:
|
||||||
configure_database(active_database_url)
|
# Registry planning may target a different database than the currently
|
||||||
|
# configured handle. The global handle is replaced either way, so
|
||||||
|
# dispose its superseded pool instead of leaking open DBAPI connections.
|
||||||
|
configure_database(active_database_url, dispose_previous=True)
|
||||||
active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories)
|
active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories)
|
||||||
raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules)
|
raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules)
|
||||||
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
|
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
|
||||||
@@ -634,7 +637,7 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
|
|||||||
|
|
||||||
def _row_count(connection, table_name: str) -> int:
|
def _row_count(connection, table_name: str) -> int:
|
||||||
quoted = _quoted_table_name(connection, table_name)
|
quoted = _quoted_table_name(connection, table_name)
|
||||||
statement = text(f"SELECT COUNT(*) FROM {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
statement = text(f"SELECT COUNT(*) FROM {quoted}") # noqa: S608 # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
return int(connection.execute(statement).scalar_one())
|
return int(connection.execute(statement).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from collections.abc import Iterable, Sequence
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
from sqlalchemy.exc import ArgumentError
|
||||||
|
|
||||||
from govoplan_core.core.discovery import iter_module_entry_points
|
from govoplan_core.core.discovery import iter_module_entry_points
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.core.modules import ModuleManifest
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
@@ -112,6 +115,14 @@ def validate_sqlite_database_url(database_url: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redacted_database_url(database_url: str) -> str:
|
||||||
|
try:
|
||||||
|
return make_url(database_url).render_as_string(hide_password=True)
|
||||||
|
except (ArgumentError, TypeError, ValueError):
|
||||||
|
scheme = database_url.partition(":")[0].strip()
|
||||||
|
return f"{scheme}:<redacted>" if scheme else "<redacted>"
|
||||||
|
|
||||||
|
|
||||||
def _env_truthy(value: str | None) -> bool:
|
def _env_truthy(value: str | None) -> bool:
|
||||||
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
|
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
@@ -297,11 +308,11 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
|
|||||||
print(f"Config: {state.config_path or DEFAULT_CONFIG}")
|
print(f"Config: {state.config_path or DEFAULT_CONFIG}")
|
||||||
print(f"Runtime root: {state.runtime_root}")
|
print(f"Runtime root: {state.runtime_root}")
|
||||||
if state.database_url:
|
if state.database_url:
|
||||||
print(f"Database: {state.database_url}")
|
print(f"Database: {redacted_database_url(state.database_url)}")
|
||||||
if state.database_url.startswith("postgresql"):
|
if state.database_url.startswith("postgresql"):
|
||||||
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
|
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
|
||||||
if pgtools_url:
|
if pgtools_url:
|
||||||
print(f"PostgreSQL tools URL: {pgtools_url}")
|
print(f"PostgreSQL tools URL: {redacted_database_url(pgtools_url)}")
|
||||||
if state.bootstrap_db_path is not None:
|
if state.bootstrap_db_path is not None:
|
||||||
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
|
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
|
||||||
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")
|
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def fetch_http(
|
|||||||
method: str = "GET",
|
method: str = "GET",
|
||||||
headers: Mapping[str, str] | None = None,
|
headers: Mapping[str, str] | None = None,
|
||||||
) -> HttpFetchResponse:
|
) -> HttpFetchResponse:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S).
|
||||||
validate_http_url(url, label=label),
|
validate_http_url(url, label=label),
|
||||||
headers=dict(headers or {}),
|
headers=dict(headers or {}),
|
||||||
method=method,
|
method=method,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
def sensitive_key_tokens(key: object) -> set[str]:
|
def sensitive_key_tokens(key: object) -> set[str]:
|
||||||
value = str(key).strip()
|
value = str(key).strip()
|
||||||
@@ -40,7 +40,7 @@ def contains_plain_secret(value: object) -> bool:
|
|||||||
normalized_key = str(key).strip().casefold().replace("-", "_")
|
normalized_key = str(key).strip().casefold().replace("-", "_")
|
||||||
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
|
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
|
||||||
continue
|
continue
|
||||||
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}):
|
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
|
||||||
return True
|
return True
|
||||||
if isinstance(item, Mapping) and contains_plain_secret(item):
|
if isinstance(item, Mapping) and contains_plain_secret(item):
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class SecretDecryptionError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
|
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
|
|||||||
+45
-8
@@ -3743,7 +3743,8 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None:
|
def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, login = self._login()
|
||||||
|
user_id = login["user"]["id"]
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"},
|
"campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"},
|
||||||
@@ -3751,10 +3752,25 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||||
"template": {"subject": "Warning test", "text": "Body"},
|
"template": {"subject": "Warning test", "text": "Body"},
|
||||||
"attachments": {"base_path": ".", "base_paths": [], "global": [{
|
"attachments": {
|
||||||
"id": "optional-missing", "base_dir": ".", "file_filter": "not-there.pdf",
|
"base_path": "review-files",
|
||||||
"required": False, "missing_behavior": "warn", "zip": {"archive_id": "exclude"}
|
"base_paths": [{
|
||||||
}], "zip": {"enabled": False, "archives": []}},
|
"id": "managed-review-files",
|
||||||
|
"name": "Review files",
|
||||||
|
"source": f"managed:user:{user_id}",
|
||||||
|
"path": "review-files",
|
||||||
|
}],
|
||||||
|
"global": [{
|
||||||
|
"id": "optional-missing",
|
||||||
|
"base_path_id": "managed-review-files",
|
||||||
|
"base_dir": "review-files",
|
||||||
|
"file_filter": "not-there.pdf",
|
||||||
|
"required": False,
|
||||||
|
"missing_behavior": "warn",
|
||||||
|
"zip": {"archive_id": "exclude"},
|
||||||
|
}],
|
||||||
|
"zip": {"enabled": False, "archives": []},
|
||||||
|
},
|
||||||
"entries": {"inline": [{"id": "warning-entry", "to": [{"email": "recipient@example.org", "type": "to"}]}]},
|
"entries": {"inline": [{"id": "warning-entry", "to": [{"email": "recipient@example.org", "type": "to"}]}]},
|
||||||
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_optional_attachment": "warn"},
|
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_optional_attachment": "warn"},
|
||||||
"delivery": {"imap_append_sent": {"enabled": False}}, "status_tracking": {"enabled": True},
|
"delivery": {"imap_append_sent": {"enabled": False}}, "status_tracking": {"enabled": True},
|
||||||
@@ -3779,7 +3795,8 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(review_state["reviewed_message_keys"], ["warning-entry"])
|
self.assertEqual(review_state["reviewed_message_keys"], ["warning-entry"])
|
||||||
|
|
||||||
def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None:
|
def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, login = self._login()
|
||||||
|
user_id = login["user"]["id"]
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"},
|
"campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"},
|
||||||
@@ -3788,10 +3805,30 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True},
|
"recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True},
|
||||||
"template": {"subject": "Hello", "text": "Active recipient only"},
|
"template": {"subject": "Hello", "text": "Active recipient only"},
|
||||||
"attachments": {"base_path": ".", "base_paths": [], "global": [], "zip": {"enabled": False, "archives": []}},
|
"attachments": {
|
||||||
|
"base_path": "inactive-files",
|
||||||
|
"base_paths": [{
|
||||||
|
"id": "managed-inactive-files",
|
||||||
|
"name": "Inactive files",
|
||||||
|
"source": f"managed:user:{user_id}",
|
||||||
|
"path": "inactive-files",
|
||||||
|
}],
|
||||||
|
"global": [],
|
||||||
|
"zip": {"enabled": False, "archives": []},
|
||||||
|
},
|
||||||
"entries": {"inline": [
|
"entries": {"inline": [
|
||||||
{"id": "active", "active": True, "to": [{"email": "active@example.org", "type": "to"}]},
|
{"id": "active", "active": True, "to": [{"email": "active@example.org", "type": "to"}]},
|
||||||
{"id": "inactive", "active": False, "to": [], "attachments": [{"base_dir": "missing", "file_filter": "missing.pdf", "required": True}]},
|
{
|
||||||
|
"id": "inactive",
|
||||||
|
"active": False,
|
||||||
|
"to": [],
|
||||||
|
"attachments": [{
|
||||||
|
"base_path_id": "managed-inactive-files",
|
||||||
|
"base_dir": "inactive-files",
|
||||||
|
"file_filter": "missing.pdf",
|
||||||
|
"required": True,
|
||||||
|
}],
|
||||||
|
},
|
||||||
]},
|
]},
|
||||||
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_required_attachment": "block"},
|
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_required_attachment": "block"},
|
||||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||||
|
|||||||
@@ -7,8 +7,22 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_core.devserver import redacted_database_url
|
||||||
|
|
||||||
|
|
||||||
class DevserverSmokeTests(unittest.TestCase):
|
class DevserverSmokeTests(unittest.TestCase):
|
||||||
|
def test_database_url_redaction_hides_passwords(self) -> None:
|
||||||
|
rendered = redacted_database_url(
|
||||||
|
"postgresql+psycopg://govoplan:database-secret@db.example.test/govoplan"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
rendered,
|
||||||
|
"postgresql+psycopg://govoplan:***@db.example.test/govoplan",
|
||||||
|
)
|
||||||
|
self.assertNotIn("database-secret", rendered)
|
||||||
|
self.assertEqual(redacted_database_url("://database-secret"), "<redacted>")
|
||||||
|
|
||||||
def test_smoke_mode_bootstraps_missing_local_sqlite_database(self) -> None:
|
def test_smoke_mode_bootstraps_missing_local_sqlite_database(self) -> None:
|
||||||
repo_root = Path(__file__).resolve().parents[1]
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
src_root = repo_root / "src"
|
src_root = repo_root / "src"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -2039,6 +2040,25 @@ finally:
|
|||||||
self.assertEqual(["check", "verify"], [record["task_id"] for record in records])
|
self.assertEqual(["check", "verify"], [record["task_id"] for record in records])
|
||||||
self.assertEqual(["warning", "warning"], [record["status"] for record in records])
|
self.assertEqual(["warning", "warning"], [record["status"] for record in records])
|
||||||
|
|
||||||
|
def test_registered_module_migration_tasks_dispose_replaced_database(self) -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-migration-dispose-", dir=_TEST_ROOT))
|
||||||
|
previous = configure_database(f"sqlite:///{root / 'previous.db'}")
|
||||||
|
previous_pool = previous.engine.pool
|
||||||
|
manifest = ModuleManifest(id="taskmod", name="Task Module", version="1.0.0")
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_registered_module_migration_tasks(
|
||||||
|
database_url=f"sqlite:///{root / 'target.db'}",
|
||||||
|
enabled_modules=("taskmod",),
|
||||||
|
phases=(),
|
||||||
|
manifest_factories=(lambda: manifest,),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNot(previous.engine.pool, previous_pool)
|
||||||
|
self.assertEqual(f"sqlite:///{root / 'target.db'}", get_database().database_url)
|
||||||
|
finally:
|
||||||
|
reset_database(dispose=True)
|
||||||
|
|
||||||
def test_module_installer_preflight_blocks_provider_update_that_breaks_installed_consumer(self) -> None:
|
def test_module_installer_preflight_blocks_provider_update_that_breaks_installed_consumer(self) -> None:
|
||||||
available = {
|
available = {
|
||||||
"files": ModuleManifest(
|
"files": ModuleManifest(
|
||||||
@@ -2562,9 +2582,33 @@ finally:
|
|||||||
self.assertEqual("external", database_backup["metadata"]["snapshot"])
|
self.assertEqual("external", database_backup["metadata"]["snapshot"])
|
||||||
self.assertEqual("postgresql://govoplan:***@db.example.invalid/govoplan", database_backup["metadata"]["pgtools_url"])
|
self.assertEqual("postgresql://govoplan:***@db.example.invalid/govoplan", database_backup["metadata"]["pgtools_url"])
|
||||||
self.assertEqual("postgresql+psycopg://govoplan:***@db.example.invalid/govoplan", database_backup["stdout"].strip())
|
self.assertEqual("postgresql+psycopg://govoplan:***@db.example.invalid/govoplan", database_backup["stdout"].strip())
|
||||||
self.assertEqual(database_url, (result.record_path.parent / database_backup["database_url_secret"]).read_text(encoding="utf-8"))
|
secret_path = result.record_path.parent / database_backup["database_url_secret"]
|
||||||
|
self.assertEqual(database_url, secret_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(0o700, stat.S_IMODE(result.record_path.parent.stat().st_mode))
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(secret_path.stat().st_mode))
|
||||||
self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8"))
|
self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_installer_secret_creation_refuses_an_existing_path(self) -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-secret-existing-", dir=_TEST_ROOT))
|
||||||
|
secret_path = root / "database-url.secret"
|
||||||
|
secret_path.write_text("existing-value", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(module_installer_module.ModuleInstallerError, "Could not create private installer secret file"):
|
||||||
|
module_installer_module._write_installer_secret(secret_path, "replacement-value")
|
||||||
|
|
||||||
|
self.assertEqual("existing-value", secret_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_installer_run_directory_remains_usable_with_a_restrictive_umask(self) -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-run-umask-", dir=_TEST_ROOT))
|
||||||
|
run_dir = root / "run"
|
||||||
|
previous_umask = os.umask(0o777)
|
||||||
|
try:
|
||||||
|
module_installer_module._create_private_installer_run_dir(run_dir)
|
||||||
|
finally:
|
||||||
|
os.umask(previous_umask)
|
||||||
|
|
||||||
|
self.assertEqual(0o700, stat.S_IMODE(run_dir.stat().st_mode))
|
||||||
|
|
||||||
def test_module_installer_rejects_unsafe_external_database_hook_command(self) -> None:
|
def test_module_installer_rejects_unsafe_external_database_hook_command(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unsafe-hook-", dir=_TEST_ROOT))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unsafe-hook-", dir=_TEST_ROOT))
|
||||||
settings = _settings(root)
|
settings = _settings(root)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||||
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||||
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
|
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
|
||||||
|
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
||||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
|
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = readFileSync("src/components/Dialog.tsx", "utf8");
|
||||||
|
const stackSource = readFileSync("src/components/dialogStack.ts", "utf8");
|
||||||
|
|
||||||
|
assert(source.includes("ref={panelRef}"), "Dialog wires its panel ref to the focus boundary");
|
||||||
|
assert(source.includes("tabIndex={-1}"), "Dialog exposes a programmatically focusable fallback");
|
||||||
|
assert(source.includes("return registerDialog({"), "Dialog registers with the shared modal stack");
|
||||||
|
assert(!source.includes('window.addEventListener("keydown"'), "Dialog instances do not install competing keyboard listeners");
|
||||||
|
assert(stackSource.includes('window.addEventListener("keydown", handleWindowKeyDown)'), "the modal stack owns one keyboard listener");
|
||||||
|
assert(stackSource.includes("handleTopDialogKeyDown(event, currentActiveElement())"), "the keyboard listener delegates only to the topmost dialog");
|
||||||
|
assert(stackSource.includes('panel.setAttribute("inert", "")'), "underlying dialog panels become inert");
|
||||||
|
assert(stackSource.includes('panel.setAttribute("aria-hidden", "true")'), "underlying dialogs leave the accessibility tree");
|
||||||
|
assert(source.includes('data-dialog-stack-state="topmost"'), "Dialog exposes stack state for custom keyboard interactions");
|
||||||
|
assert(source.includes("dialogIsTopmost(stackIdRef.current)"), "underlying backdrops cannot close their dialog");
|
||||||
|
assert(source.includes("shouldCloseDialogOnBackdrop(event.target, event.currentTarget, closeOnBackdrop, canClose)"), "Dialog preserves explicit backdrop-close conditions");
|
||||||
|
|
||||||
|
console.log("Dialog focus and stack lifecycle structure checks passed.");
|
||||||
@@ -12,6 +12,7 @@ const packageByModule = {
|
|||||||
files: "@govoplan/files-webui",
|
files: "@govoplan/files-webui",
|
||||||
idm: "@govoplan/idm-webui",
|
idm: "@govoplan/idm-webui",
|
||||||
mail: "@govoplan/mail-webui",
|
mail: "@govoplan/mail-webui",
|
||||||
|
notifications: "@govoplan/notifications-webui",
|
||||||
organizations: "@govoplan/organizations-webui",
|
organizations: "@govoplan/organizations-webui",
|
||||||
ops: "@govoplan/ops-webui",
|
ops: "@govoplan/ops-webui",
|
||||||
policy: "@govoplan/policy-webui",
|
policy: "@govoplan/policy-webui",
|
||||||
@@ -29,6 +30,7 @@ const cases = [
|
|||||||
{ name: "calendar-only", modules: ["calendar"] },
|
{ name: "calendar-only", modules: ["calendar"] },
|
||||||
{ name: "files-only", modules: ["files"] },
|
{ name: "files-only", modules: ["files"] },
|
||||||
{ name: "mail-only", modules: ["mail"] },
|
{ name: "mail-only", modules: ["mail"] },
|
||||||
|
{ name: "notifications-only", modules: ["notifications"] },
|
||||||
{ name: "organizations-only", modules: ["organizations"] },
|
{ name: "organizations-only", modules: ["organizations"] },
|
||||||
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
|
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
|
||||||
{ name: "campaign-only", modules: ["campaigns"] },
|
{ name: "campaign-only", modules: ["campaigns"] },
|
||||||
@@ -37,7 +39,7 @@ const cases = [
|
|||||||
{ name: "scheduling-only", modules: ["scheduling"] },
|
{ name: "scheduling-only", modules: ["scheduling"] },
|
||||||
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
||||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||||
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "calendar", "scheduling"] }
|
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const npmExec = process.env.npm_execpath;
|
const npmExec = process.env.npm_execpath;
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useId, type ReactNode } from "react";
|
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||||
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
|
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
import { shouldCloseDialogOnBackdrop } from "./dialogInteractions";
|
||||||
|
import {
|
||||||
|
dialogIsTopmost,
|
||||||
|
nextDialogActivationOrder,
|
||||||
|
registerDialog,
|
||||||
|
type DialogStackId
|
||||||
|
} from "./dialogStack";
|
||||||
|
|
||||||
export type DialogProps = {
|
export type DialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -46,6 +53,20 @@ export default function Dialog({
|
|||||||
}: DialogProps) {
|
}: DialogProps) {
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const canClose = Boolean(onClose) && !closeDisabled;
|
const canClose = Boolean(onClose) && !closeDisabled;
|
||||||
|
const panelRef = useRef<HTMLElement | null>(null);
|
||||||
|
const stackIdRef = useRef<DialogStackId>(Symbol("govoplan-dialog"));
|
||||||
|
const activationOrderRef = useRef<number | null>(null);
|
||||||
|
const canCloseRef = useRef(canClose);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
const restoreFocusRef = useRef<HTMLElement | null>(
|
||||||
|
typeof document !== "undefined" && typeof HTMLElement !== "undefined" && document.activeElement instanceof HTMLElement
|
||||||
|
? document.activeElement
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
canCloseRef.current = canClose;
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
if (!open) activationOrderRef.current = null;
|
||||||
|
else if (activationOrderRef.current === null) activationOrderRef.current = nextDialogActivationOrder();
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const renderedTitle = translateReactNode(title, translateText);
|
const renderedTitle = translateReactNode(title, translateText);
|
||||||
const renderedChildren = translateReactNode(children, translateText);
|
const renderedChildren = translateReactNode(children, translateText);
|
||||||
@@ -53,15 +74,36 @@ export default function Dialog({
|
|||||||
const translatedCloseLabel = translateText(closeLabel);
|
const translatedCloseLabel = translateText(closeLabel);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !canClose) return undefined;
|
if (open || typeof document === "undefined") return undefined;
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const rememberFocusedElement = () => {
|
||||||
if (event.key === "Escape") onClose?.();
|
const activeElement = document.activeElement;
|
||||||
|
if (!(activeElement instanceof HTMLElement)) return;
|
||||||
|
if (panelRef.current?.contains(activeElement)) return;
|
||||||
|
restoreFocusRef.current = activeElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
rememberFocusedElement();
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
document.addEventListener("focusin", rememberFocusedElement);
|
||||||
}, [canClose, onClose, open]);
|
return () => document.removeEventListener("focusin", rememberFocusedElement);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || typeof document === "undefined") return undefined;
|
||||||
|
|
||||||
|
const panel = panelRef.current;
|
||||||
|
const activationOrder = activationOrderRef.current;
|
||||||
|
if (!panel || activationOrder === null) return undefined;
|
||||||
|
|
||||||
|
return registerDialog({
|
||||||
|
id: stackIdRef.current,
|
||||||
|
activationOrder,
|
||||||
|
panel,
|
||||||
|
restoreFocus: restoreFocusRef.current,
|
||||||
|
canClose: () => canCloseRef.current,
|
||||||
|
onClose: () => onCloseRef.current?.()
|
||||||
|
}, document.activeElement);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -70,13 +112,19 @@ export default function Dialog({
|
|||||||
className={joinClasses("dialog-backdrop", backdropClassName)}
|
className={joinClasses("dialog-backdrop", backdropClassName)}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (closeOnBackdrop && canClose && event.target === event.currentTarget) onClose?.();
|
if (
|
||||||
|
dialogIsTopmost(stackIdRef.current)
|
||||||
|
&& shouldCloseDialogOnBackdrop(event.target, event.currentTarget, closeOnBackdrop, canClose)
|
||||||
|
) onClose?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
|
ref={panelRef}
|
||||||
|
tabIndex={-1}
|
||||||
className={joinClasses("dialog-panel", className)}
|
className={joinClasses("dialog-panel", className)}
|
||||||
role={role}
|
role={role}
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
data-dialog-stack-state="topmost"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-describedby={ariaDescribedBy}
|
aria-describedby={ariaDescribedBy}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
const FOCUSABLE_SELECTOR = [
|
||||||
|
"a[href]",
|
||||||
|
"area[href]",
|
||||||
|
"button:not([disabled])",
|
||||||
|
"input:not([disabled]):not([type=\"hidden\"])",
|
||||||
|
"select:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
"details > summary:first-of-type",
|
||||||
|
"iframe",
|
||||||
|
"object",
|
||||||
|
"embed",
|
||||||
|
"[contenteditable]:not([contenteditable=\"false\"])",
|
||||||
|
"[tabindex]:not([tabindex=\"-1\"])"
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
export type DialogKeyboardEvent = Pick<KeyboardEvent, "key" | "shiftKey" | "preventDefault">;
|
||||||
|
|
||||||
|
function elementIsAvailable(element: HTMLElement): boolean {
|
||||||
|
if (element.tabIndex < 0 || element.hasAttribute("disabled")) return false;
|
||||||
|
if (element.closest('[hidden], [inert], [aria-hidden="true"]')) return false;
|
||||||
|
if (typeof window === "undefined") return true;
|
||||||
|
try {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return style.display !== "none" && style.visibility !== "hidden";
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusWithoutScrolling(element: HTMLElement): void {
|
||||||
|
try {
|
||||||
|
element.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dialogFocusableElements(panel: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(elementIsAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusDialogOnOpen(panel: HTMLElement, activeElement: Element | null): HTMLElement {
|
||||||
|
if (activeElement && panel.contains(activeElement)) return activeElement as HTMLElement;
|
||||||
|
const focusable = dialogFocusableElements(panel);
|
||||||
|
const requested = panel.querySelector<HTMLElement>("[autofocus]");
|
||||||
|
const target = requested && focusable.includes(requested) ? requested : (focusable[0] ?? panel);
|
||||||
|
focusWithoutScrolling(target);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trapDialogFocus(
|
||||||
|
panel: HTMLElement,
|
||||||
|
event: DialogKeyboardEvent,
|
||||||
|
activeElement: Element | null
|
||||||
|
): boolean {
|
||||||
|
if (event.key !== "Tab") return false;
|
||||||
|
|
||||||
|
const focusable = dialogFocusableElements(panel);
|
||||||
|
let target: HTMLElement | null = null;
|
||||||
|
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
target = panel;
|
||||||
|
} else {
|
||||||
|
const activeIndex = activeElement ? focusable.indexOf(activeElement as HTMLElement) : -1;
|
||||||
|
if (activeIndex < 0) {
|
||||||
|
target = event.shiftKey ? focusable[focusable.length - 1] : focusable[0];
|
||||||
|
} else if (event.shiftKey && activeIndex === 0) {
|
||||||
|
target = focusable[focusable.length - 1];
|
||||||
|
} else if (!event.shiftKey && activeIndex === focusable.length - 1) {
|
||||||
|
target = focusable[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
focusWithoutScrolling(target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleDialogKeyDown(
|
||||||
|
panel: HTMLElement,
|
||||||
|
event: DialogKeyboardEvent,
|
||||||
|
activeElement: Element | null,
|
||||||
|
canClose: boolean,
|
||||||
|
onClose?: () => void
|
||||||
|
): boolean {
|
||||||
|
if (event.key === "Tab") return trapDialogFocus(panel, event, activeElement);
|
||||||
|
if (event.key !== "Escape" || !canClose) return false;
|
||||||
|
onClose?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreDialogFocus(element: HTMLElement | null): boolean {
|
||||||
|
if (!element || element.isConnected === false) return false;
|
||||||
|
focusWithoutScrolling(element);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCloseDialogOnBackdrop(
|
||||||
|
target: EventTarget | null,
|
||||||
|
currentTarget: EventTarget | null,
|
||||||
|
closeOnBackdrop: boolean,
|
||||||
|
canClose: boolean
|
||||||
|
): boolean {
|
||||||
|
return closeOnBackdrop && canClose && target === currentTarget;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
focusDialogOnOpen,
|
||||||
|
handleDialogKeyDown,
|
||||||
|
restoreDialogFocus,
|
||||||
|
type DialogKeyboardEvent
|
||||||
|
} from "./dialogInteractions";
|
||||||
|
|
||||||
|
export type DialogStackId = symbol;
|
||||||
|
|
||||||
|
export type DialogStackRegistration = {
|
||||||
|
id: DialogStackId;
|
||||||
|
activationOrder: number;
|
||||||
|
panel: HTMLElement;
|
||||||
|
restoreFocus: HTMLElement | null;
|
||||||
|
canClose: () => boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DialogStackEntry = DialogStackRegistration & {
|
||||||
|
restoreTargets: HTMLElement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDialogs: DialogStackEntry[] = [];
|
||||||
|
let activationSequence = 0;
|
||||||
|
let listeningForKeyDown = false;
|
||||||
|
|
||||||
|
function topDialog(): DialogStackEntry | null {
|
||||||
|
return openDialogs[openDialogs.length - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentActiveElement(): Element | null {
|
||||||
|
return typeof document === "undefined" ? null : document.activeElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendUniqueTargets(targets: HTMLElement[], additions: Array<HTMLElement | null>): HTMLElement[] {
|
||||||
|
const next = [...targets];
|
||||||
|
additions.forEach((target) => {
|
||||||
|
if (target && !next.includes(target)) next.push(target);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exposeAsTopmost(panel: HTMLElement): void {
|
||||||
|
panel.removeAttribute("inert");
|
||||||
|
panel.removeAttribute("aria-hidden");
|
||||||
|
panel.setAttribute("aria-modal", "true");
|
||||||
|
panel.setAttribute("data-dialog-stack-state", "topmost");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAsUnderlying(panel: HTMLElement): void {
|
||||||
|
panel.setAttribute("inert", "");
|
||||||
|
panel.setAttribute("aria-hidden", "true");
|
||||||
|
panel.removeAttribute("aria-modal");
|
||||||
|
panel.setAttribute("data-dialog-stack-state", "underlying");
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncStackAccessibility(): void {
|
||||||
|
const topIndex = openDialogs.length - 1;
|
||||||
|
openDialogs.forEach((entry, index) => {
|
||||||
|
if (index === topIndex) exposeAsTopmost(entry.panel);
|
||||||
|
else hideAsUnderlying(entry.panel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowKeyDown(event: KeyboardEvent): void {
|
||||||
|
handleTopDialogKeyDown(event, currentActiveElement());
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncWindowKeyDownListener(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (openDialogs.length > 0 && !listeningForKeyDown) {
|
||||||
|
window.addEventListener("keydown", handleWindowKeyDown);
|
||||||
|
listeningForKeyDown = true;
|
||||||
|
} else if (openDialogs.length === 0 && listeningForKeyDown) {
|
||||||
|
window.removeEventListener("keydown", handleWindowKeyDown);
|
||||||
|
listeningForKeyDown = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusAfterTopmostCloses(entry: DialogStackEntry): void {
|
||||||
|
const nextTop = topDialog();
|
||||||
|
if (nextTop) {
|
||||||
|
const targetInsideNextDialog = entry.restoreTargets.find(
|
||||||
|
(target) => target.isConnected !== false && nextTop.panel.contains(target)
|
||||||
|
);
|
||||||
|
if (targetInsideNextDialog && restoreDialogFocus(targetInsideNextDialog)) return;
|
||||||
|
focusDialogOnOpen(nextTop.panel, currentActiveElement());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.restoreTargets.some((target) => restoreDialogFocus(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterDialog(id: DialogStackId): void {
|
||||||
|
const index = openDialogs.findIndex((entry) => entry.id === id);
|
||||||
|
if (index < 0) return;
|
||||||
|
|
||||||
|
const wasTopmost = index === openDialogs.length - 1;
|
||||||
|
const [entry] = openDialogs.splice(index, 1);
|
||||||
|
exposeAsTopmost(entry.panel);
|
||||||
|
syncStackAccessibility();
|
||||||
|
syncWindowKeyDownListener();
|
||||||
|
|
||||||
|
if (wasTopmost) focusAfterTopmostCloses(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextDialogActivationOrder(): number {
|
||||||
|
activationSequence += 1;
|
||||||
|
return activationSequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerDialog(
|
||||||
|
registration: DialogStackRegistration,
|
||||||
|
activeElement: Element | null
|
||||||
|
): () => void {
|
||||||
|
const existingIndex = openDialogs.findIndex((entry) => entry.id === registration.id);
|
||||||
|
if (existingIndex >= 0) openDialogs.splice(existingIndex, 1);
|
||||||
|
|
||||||
|
const entry: DialogStackEntry = {
|
||||||
|
...registration,
|
||||||
|
restoreTargets: appendUniqueTargets([], [registration.restoreFocus])
|
||||||
|
};
|
||||||
|
openDialogs.push(entry);
|
||||||
|
openDialogs.sort((left, right) => left.activationOrder - right.activationOrder);
|
||||||
|
|
||||||
|
const entryIndex = openDialogs.indexOf(entry);
|
||||||
|
const entryBelow = openDialogs[entryIndex - 1];
|
||||||
|
if (entryBelow) entry.restoreTargets = appendUniqueTargets(entry.restoreTargets, entryBelow.restoreTargets);
|
||||||
|
for (let index = entryIndex + 1; index < openDialogs.length; index += 1) {
|
||||||
|
openDialogs[index].restoreTargets = appendUniqueTargets(openDialogs[index].restoreTargets, entry.restoreTargets);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topDialog() === entry) focusDialogOnOpen(entry.panel, activeElement);
|
||||||
|
syncStackAccessibility();
|
||||||
|
syncWindowKeyDownListener();
|
||||||
|
|
||||||
|
return () => unregisterDialog(registration.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleTopDialogKeyDown(
|
||||||
|
event: DialogKeyboardEvent,
|
||||||
|
activeElement: Element | null
|
||||||
|
): boolean {
|
||||||
|
const entry = topDialog();
|
||||||
|
if (!entry) return false;
|
||||||
|
return handleDialogKeyDown(entry.panel, event, activeElement, entry.canClose(), entry.onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dialogIsTopmost(id: DialogStackId): boolean {
|
||||||
|
const entry = topDialog();
|
||||||
|
return !entry || entry.id === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetDialogStackForTests(): void {
|
||||||
|
openDialogs.splice(0).forEach((entry) => exposeAsTopmost(entry.panel));
|
||||||
|
activationSequence = 0;
|
||||||
|
syncWindowKeyDownListener();
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
|
||||||
|
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import Dialog from "../src/components/Dialog";
|
||||||
|
import {
|
||||||
|
focusDialogOnOpen,
|
||||||
|
handleDialogKeyDown,
|
||||||
|
restoreDialogFocus,
|
||||||
|
shouldCloseDialogOnBackdrop,
|
||||||
|
trapDialogFocus
|
||||||
|
} from "../src/components/dialogInteractions";
|
||||||
|
import {
|
||||||
|
handleTopDialogKeyDown,
|
||||||
|
nextDialogActivationOrder,
|
||||||
|
registerDialog,
|
||||||
|
resetDialogStackForTests
|
||||||
|
} from "../src/components/dialogStack";
|
||||||
|
|
||||||
|
type FocusableFixture = HTMLElement & { focusCount: number; attributes: Map<string, string> };
|
||||||
|
|
||||||
|
function focusableFixture({ connected = true, hidden = false }: { connected?: boolean; hidden?: boolean } = {}): FocusableFixture {
|
||||||
|
const attributes = new Map<string, string>();
|
||||||
|
if (hidden) attributes.set("hidden", "");
|
||||||
|
const fixture = {
|
||||||
|
attributes,
|
||||||
|
focusCount: 0,
|
||||||
|
tabIndex: 0,
|
||||||
|
isConnected: connected,
|
||||||
|
getAttribute: (name: string) => attributes.get(name) ?? null,
|
||||||
|
hasAttribute: (name: string) => attributes.has(name),
|
||||||
|
setAttribute: (name: string, value: string) => attributes.set(name, value),
|
||||||
|
removeAttribute: (name: string) => attributes.delete(name),
|
||||||
|
closest: () => attributes.has("hidden") || attributes.has("inert") || attributes.get("aria-hidden") === "true" ? fixture : null,
|
||||||
|
focus() {
|
||||||
|
fixture.focusCount += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return fixture as unknown as FocusableFixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelFixture(elements: FocusableFixture[], autofocus?: FocusableFixture) {
|
||||||
|
const panel = focusableFixture();
|
||||||
|
panel.tabIndex = -1;
|
||||||
|
Object.assign(panel, {
|
||||||
|
contains: (element: Element | null) => element === panel || elements.includes(element as FocusableFixture),
|
||||||
|
querySelectorAll: () => elements,
|
||||||
|
querySelector: (selector: string) => selector === "[autofocus]" ? (autofocus ?? null) : null
|
||||||
|
});
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardEvent(key: string, shiftKey = false) {
|
||||||
|
const fixture = {
|
||||||
|
key,
|
||||||
|
shiftKey,
|
||||||
|
prevented: 0,
|
||||||
|
preventDefault() {
|
||||||
|
fixture.prevented += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<Dialog open title="Dialog title" onClose={() => undefined}>
|
||||||
|
<button type="button">First action</button>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
assert(markup.includes('role="dialog"'), "dialog semantics are preserved");
|
||||||
|
assert(markup.includes('aria-modal="true"'), "modal semantics are preserved");
|
||||||
|
assert(markup.includes('data-dialog-stack-state="topmost"'), "dialog exposes its initial stack state");
|
||||||
|
assert(markup.includes('tabindex="-1"'), "dialog panel can receive fallback focus");
|
||||||
|
|
||||||
|
const first = focusableFixture();
|
||||||
|
const requested = focusableFixture();
|
||||||
|
const entryPanel = panelFixture([first, requested], requested);
|
||||||
|
assertEqual(focusDialogOnOpen(entryPanel, null), requested, "autofocus target receives initial focus");
|
||||||
|
assertEqual(requested.focusCount, 1, "autofocus target is focused once");
|
||||||
|
|
||||||
|
const defaultEntryPanel = panelFixture([first]);
|
||||||
|
assertEqual(focusDialogOnOpen(defaultEntryPanel, null), first, "first available control receives initial focus");
|
||||||
|
assertEqual(first.focusCount, 1, "first available control is focused once");
|
||||||
|
|
||||||
|
const retained = focusableFixture();
|
||||||
|
const retainedPanel = panelFixture([retained]);
|
||||||
|
assertEqual(focusDialogOnOpen(retainedPanel, retained), retained, "existing focus inside the dialog is retained");
|
||||||
|
assertEqual(retained.focusCount, 0, "retained focus is not moved redundantly");
|
||||||
|
|
||||||
|
const emptyPanel = panelFixture([]);
|
||||||
|
assertEqual(focusDialogOnOpen(emptyPanel, null), emptyPanel, "panel receives focus when no control is available");
|
||||||
|
assertEqual(emptyPanel.focusCount, 1, "fallback panel is focused");
|
||||||
|
|
||||||
|
const boundaryFirst = focusableFixture();
|
||||||
|
const boundaryMiddle = focusableFixture();
|
||||||
|
const boundaryLast = focusableFixture();
|
||||||
|
const boundaryPanel = panelFixture([boundaryFirst, boundaryMiddle, boundaryLast]);
|
||||||
|
|
||||||
|
const forwardWrap = keyboardEvent("Tab");
|
||||||
|
assert(trapDialogFocus(boundaryPanel, forwardWrap, boundaryLast), "forward Tab wraps at the last control");
|
||||||
|
assertEqual(forwardWrap.prevented, 1, "forward wrap prevents focus from leaving the dialog");
|
||||||
|
assertEqual(boundaryFirst.focusCount, 1, "forward wrap focuses the first control");
|
||||||
|
|
||||||
|
const backwardWrap = keyboardEvent("Tab", true);
|
||||||
|
assert(trapDialogFocus(boundaryPanel, backwardWrap, boundaryFirst), "Shift+Tab wraps at the first control");
|
||||||
|
assertEqual(backwardWrap.prevented, 1, "backward wrap prevents focus from leaving the dialog");
|
||||||
|
assertEqual(boundaryLast.focusCount, 1, "backward wrap focuses the last control");
|
||||||
|
|
||||||
|
const interiorTab = keyboardEvent("Tab");
|
||||||
|
assert(!trapDialogFocus(boundaryPanel, interiorTab, boundaryMiddle), "interior Tab keeps native focus order");
|
||||||
|
assertEqual(interiorTab.prevented, 0, "interior Tab is not prevented");
|
||||||
|
|
||||||
|
const outsideTab = keyboardEvent("Tab");
|
||||||
|
assert(trapDialogFocus(boundaryPanel, outsideTab, focusableFixture()), "focus cannot enter the modal from outside its boundary");
|
||||||
|
assertEqual(boundaryFirst.focusCount, 2, "outside focus is redirected to the first control");
|
||||||
|
|
||||||
|
let closeCount = 0;
|
||||||
|
const escape = keyboardEvent("Escape");
|
||||||
|
assert(handleDialogKeyDown(boundaryPanel, escape, boundaryFirst, true, () => { closeCount += 1; }), "Escape closes a closable dialog");
|
||||||
|
assertEqual(closeCount, 1, "Escape invokes onClose once");
|
||||||
|
assertEqual(escape.prevented, 0, "Escape preserves the existing default-event behavior");
|
||||||
|
|
||||||
|
const blockedEscape = keyboardEvent("Escape");
|
||||||
|
assert(!handleDialogKeyDown(boundaryPanel, blockedEscape, boundaryFirst, false, () => { closeCount += 1; }), "Escape is ignored while closing is disabled");
|
||||||
|
assertEqual(closeCount, 1, "disabled Escape does not invoke onClose");
|
||||||
|
|
||||||
|
const opener = focusableFixture();
|
||||||
|
assert(restoreDialogFocus(opener), "connected opener focus is restored");
|
||||||
|
assertEqual(opener.focusCount, 1, "opener receives restored focus once");
|
||||||
|
assert(!restoreDialogFocus(focusableFixture({ connected: false })), "removed opener is not focused");
|
||||||
|
|
||||||
|
const backdrop = {} as EventTarget;
|
||||||
|
const child = {} as EventTarget;
|
||||||
|
assert(shouldCloseDialogOnBackdrop(backdrop, backdrop, true, true), "direct backdrop press closes a closable dialog");
|
||||||
|
assert(!shouldCloseDialogOnBackdrop(child, backdrop, true, true), "presses inside the panel do not close the dialog");
|
||||||
|
assert(!shouldCloseDialogOnBackdrop(backdrop, backdrop, false, true), "backdrop closing can be disabled");
|
||||||
|
assert(!shouldCloseDialogOnBackdrop(backdrop, backdrop, true, false), "backdrop press respects closeDisabled");
|
||||||
|
|
||||||
|
resetDialogStackForTests();
|
||||||
|
const outerOpener = focusableFixture();
|
||||||
|
const parentOpener = focusableFixture();
|
||||||
|
const parentLast = focusableFixture();
|
||||||
|
const parentPanel = panelFixture([parentOpener, parentLast]);
|
||||||
|
const childFirst = focusableFixture();
|
||||||
|
const childLast = focusableFixture();
|
||||||
|
const childPanel = panelFixture([childFirst, childLast]);
|
||||||
|
const parentId = Symbol("parent-dialog");
|
||||||
|
const childId = Symbol("child-dialog");
|
||||||
|
const parentOrder = nextDialogActivationOrder();
|
||||||
|
const childOrder = nextDialogActivationOrder();
|
||||||
|
let parentCloseCount = 0;
|
||||||
|
let childCloseCount = 0;
|
||||||
|
let childCanClose = false;
|
||||||
|
|
||||||
|
// React may commit a nested child's effect before its parent effect. Activation
|
||||||
|
// order is allocated during render so the child must remain topmost either way.
|
||||||
|
const unregisterChild = registerDialog({
|
||||||
|
id: childId,
|
||||||
|
activationOrder: childOrder,
|
||||||
|
panel: childPanel,
|
||||||
|
restoreFocus: parentOpener,
|
||||||
|
canClose: () => childCanClose,
|
||||||
|
onClose: () => { childCloseCount += 1; }
|
||||||
|
}, parentOpener);
|
||||||
|
const unregisterParent = registerDialog({
|
||||||
|
id: parentId,
|
||||||
|
activationOrder: parentOrder,
|
||||||
|
panel: parentPanel,
|
||||||
|
restoreFocus: outerOpener,
|
||||||
|
canClose: () => true,
|
||||||
|
onClose: () => { parentCloseCount += 1; }
|
||||||
|
}, childFirst);
|
||||||
|
|
||||||
|
assertEqual(parentPanel.getAttribute("data-dialog-stack-state"), "underlying", "parent dialog is marked as underlying");
|
||||||
|
assert(parentPanel.hasAttribute("inert"), "parent dialog is inert while a child is open");
|
||||||
|
assertEqual(parentPanel.getAttribute("aria-hidden"), "true", "parent dialog is hidden from assistive technology");
|
||||||
|
assertEqual(parentPanel.getAttribute("aria-modal"), null, "underlying dialog no longer claims modal semantics");
|
||||||
|
assertEqual(childPanel.getAttribute("data-dialog-stack-state"), "topmost", "child dialog is marked as topmost");
|
||||||
|
assertEqual(childPanel.getAttribute("aria-modal"), "true", "child dialog owns modal semantics");
|
||||||
|
|
||||||
|
const stackedTab = keyboardEvent("Tab");
|
||||||
|
const parentFocusBeforeTab = parentOpener.focusCount;
|
||||||
|
assert(handleTopDialogKeyDown(stackedTab, childLast), "topmost dialog traps Tab");
|
||||||
|
assertEqual(stackedTab.prevented, 1, "stacked Tab wrapping is prevented once");
|
||||||
|
assertEqual(parentOpener.focusCount, parentFocusBeforeTab, "underlying dialog does not process Tab");
|
||||||
|
|
||||||
|
const disabledStackedEscape = keyboardEvent("Escape");
|
||||||
|
assert(!handleTopDialogKeyDown(disabledStackedEscape, childFirst), "disabled child ignores Escape without falling through");
|
||||||
|
assertEqual(childCloseCount, 0, "disabled child remains open");
|
||||||
|
assertEqual(parentCloseCount, 0, "disabled child Escape does not close the parent");
|
||||||
|
|
||||||
|
childCanClose = true;
|
||||||
|
const stackedEscape = keyboardEvent("Escape");
|
||||||
|
assert(handleTopDialogKeyDown(stackedEscape, childFirst), "topmost dialog handles Escape");
|
||||||
|
assertEqual(childCloseCount, 1, "one Escape closes the child once");
|
||||||
|
assertEqual(parentCloseCount, 0, "one Escape does not close the parent");
|
||||||
|
|
||||||
|
const parentFocusBeforeChildClose = parentOpener.focusCount;
|
||||||
|
unregisterChild();
|
||||||
|
assertEqual(parentOpener.focusCount, parentFocusBeforeChildClose + 1, "closing the child restores focus inside the parent");
|
||||||
|
assert(!parentPanel.hasAttribute("inert"), "parent dialog becomes interactive after the child closes");
|
||||||
|
assertEqual(parentPanel.getAttribute("aria-hidden"), null, "parent dialog returns to the accessibility tree");
|
||||||
|
assertEqual(parentPanel.getAttribute("aria-modal"), "true", "parent dialog regains modal semantics");
|
||||||
|
assertEqual(parentPanel.getAttribute("data-dialog-stack-state"), "topmost", "parent dialog becomes topmost");
|
||||||
|
|
||||||
|
const parentEscape = keyboardEvent("Escape");
|
||||||
|
assert(handleTopDialogKeyDown(parentEscape, parentOpener), "parent handles Escape after child cleanup");
|
||||||
|
assertEqual(parentCloseCount, 1, "parent closes only on its own Escape handling");
|
||||||
|
unregisterParent();
|
||||||
|
assertEqual(outerOpener.focusCount, 1, "closing the final dialog restores the outer opener");
|
||||||
|
assert(!handleTopDialogKeyDown(keyboardEvent("Escape"), outerOpener), "keyboard handling stops when the stack is empty");
|
||||||
|
|
||||||
|
resetDialogStackForTests();
|
||||||
|
const forcedOuterOpener = focusableFixture();
|
||||||
|
const removedParentOpener = focusableFixture();
|
||||||
|
const removedParentPanel = panelFixture([removedParentOpener]);
|
||||||
|
const forcedChildControl = focusableFixture();
|
||||||
|
const forcedChildPanel = panelFixture([forcedChildControl]);
|
||||||
|
const unregisterForcedParent = registerDialog({
|
||||||
|
id: Symbol("forced-parent-dialog"),
|
||||||
|
activationOrder: nextDialogActivationOrder(),
|
||||||
|
panel: removedParentPanel,
|
||||||
|
restoreFocus: forcedOuterOpener,
|
||||||
|
canClose: () => true
|
||||||
|
}, forcedOuterOpener);
|
||||||
|
const unregisterForcedChild = registerDialog({
|
||||||
|
id: Symbol("forced-child-dialog"),
|
||||||
|
activationOrder: nextDialogActivationOrder(),
|
||||||
|
panel: forcedChildPanel,
|
||||||
|
restoreFocus: removedParentOpener,
|
||||||
|
canClose: () => true
|
||||||
|
}, removedParentOpener);
|
||||||
|
unregisterForcedParent();
|
||||||
|
Object.assign(removedParentOpener, { isConnected: false });
|
||||||
|
unregisterForcedChild();
|
||||||
|
assertEqual(forcedOuterOpener.focusCount, 1, "removing a dialog tree restores the nearest connected outer opener");
|
||||||
|
|
||||||
|
resetDialogStackForTests();
|
||||||
|
const strictModeOpener = focusableFixture();
|
||||||
|
const strictModeControl = focusableFixture();
|
||||||
|
const strictModePanel = panelFixture([strictModeControl]);
|
||||||
|
const strictModeRegistration = {
|
||||||
|
id: Symbol("strict-mode-dialog"),
|
||||||
|
activationOrder: nextDialogActivationOrder(),
|
||||||
|
panel: strictModePanel,
|
||||||
|
restoreFocus: strictModeOpener,
|
||||||
|
canClose: () => true
|
||||||
|
};
|
||||||
|
const unregisterStrictModeFirstPass = registerDialog(strictModeRegistration, strictModeOpener);
|
||||||
|
unregisterStrictModeFirstPass();
|
||||||
|
const unregisterStrictModeReplay = registerDialog(strictModeRegistration, strictModeOpener);
|
||||||
|
assertEqual(strictModeControl.focusCount, 2, "StrictMode effect replay returns focus to the dialog");
|
||||||
|
assertEqual(strictModeOpener.focusCount, 1, "StrictMode cleanup restores the opener before replay");
|
||||||
|
unregisterStrictModeReplay();
|
||||||
|
assertEqual(strictModeOpener.focusCount, 2, "StrictMode replay cleanup restores focus without leaving a stack entry");
|
||||||
|
assert(!handleTopDialogKeyDown(keyboardEvent("Escape"), strictModeOpener), "StrictMode replay leaves no stale topmost dialog");
|
||||||
|
|
||||||
|
resetDialogStackForTests();
|
||||||
|
let keydownListenerAdds = 0;
|
||||||
|
let keydownListenerRemovals = 0;
|
||||||
|
Object.defineProperty(globalThis, "window", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener(type: string) {
|
||||||
|
if (type === "keydown") keydownListenerAdds += 1;
|
||||||
|
},
|
||||||
|
removeEventListener(type: string) {
|
||||||
|
if (type === "keydown") keydownListenerRemovals += 1;
|
||||||
|
},
|
||||||
|
getComputedStyle() {
|
||||||
|
return { display: "block", visibility: "visible" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const listenerOuter = focusableFixture();
|
||||||
|
const listenerParentPanel = panelFixture([focusableFixture()]);
|
||||||
|
const listenerChildPanel = panelFixture([focusableFixture()]);
|
||||||
|
const unregisterListenerParent = registerDialog({
|
||||||
|
id: Symbol("listener-parent"),
|
||||||
|
activationOrder: nextDialogActivationOrder(),
|
||||||
|
panel: listenerParentPanel,
|
||||||
|
restoreFocus: listenerOuter,
|
||||||
|
canClose: () => true
|
||||||
|
}, listenerOuter);
|
||||||
|
const unregisterListenerChild = registerDialog({
|
||||||
|
id: Symbol("listener-child"),
|
||||||
|
activationOrder: nextDialogActivationOrder(),
|
||||||
|
panel: listenerChildPanel,
|
||||||
|
restoreFocus: listenerParentPanel,
|
||||||
|
canClose: () => true
|
||||||
|
}, listenerParentPanel);
|
||||||
|
assertEqual(keydownListenerAdds, 1, "one window keyboard listener serves the whole dialog stack");
|
||||||
|
unregisterListenerChild();
|
||||||
|
assertEqual(keydownListenerRemovals, 0, "keyboard listener remains while a parent dialog is open");
|
||||||
|
unregisterListenerParent();
|
||||||
|
assertEqual(keydownListenerRemovals, 1, "keyboard listener is removed after the final dialog closes");
|
||||||
|
} finally {
|
||||||
|
resetDialogStackForTests();
|
||||||
|
Reflect.deleteProperty(globalThis, "window");
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"tests/data-grid-actions.test.tsx",
|
"tests/data-grid-actions.test.tsx",
|
||||||
|
"tests/dialog-focus.test.tsx",
|
||||||
"tests/mail-components.test.tsx",
|
"tests/mail-components.test.tsx",
|
||||||
"src/components/CredentialPanel.tsx",
|
"src/components/CredentialPanel.tsx",
|
||||||
"src/components/email/EmailAddressInput.tsx",
|
"src/components/email/EmailAddressInput.tsx",
|
||||||
@@ -27,6 +28,9 @@
|
|||||||
"src/components/mail/MailServerSettingsPanel.tsx",
|
"src/components/mail/MailServerSettingsPanel.tsx",
|
||||||
"src/components/Button.tsx",
|
"src/components/Button.tsx",
|
||||||
"src/components/DismissibleAlert.tsx",
|
"src/components/DismissibleAlert.tsx",
|
||||||
|
"src/components/Dialog.tsx",
|
||||||
|
"src/components/dialogInteractions.ts",
|
||||||
|
"src/components/dialogStack.ts",
|
||||||
"src/components/FormField.tsx",
|
"src/components/FormField.tsx",
|
||||||
"src/components/ToggleSwitch.tsx"
|
"src/components/ToggleSwitch.tsx"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user