Compare commits
22
Commits
7184b6cdd6
...
ae74189588
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae74189588 | ||
|
|
09b5009187 | ||
|
|
2ca61059dc | ||
|
|
865901f090 | ||
|
|
2ac1e64daa | ||
|
|
7526c5ebb2 | ||
|
|
8e1f64c790 | ||
|
|
66e4783d2e | ||
|
|
7af86b42eb | ||
|
|
ad202f1267 | ||
|
|
6526f37aae | ||
|
|
9dabd9356d | ||
|
|
6502775bf7 | ||
|
|
b2492b820f | ||
|
|
78d9ae48b2 | ||
|
|
4cb3e94de3 | ||
|
|
9131838b98 | ||
|
|
8e9eb6e1f5 | ||
|
|
249bf63eb8 | ||
|
|
248e3dc70e | ||
|
|
230ecf42b0 | ||
|
|
825791e9b0 |
@@ -192,15 +192,60 @@ prefer `FILE_STORAGE_*`.
|
|||||||
| Setting | Default | Notes |
|
| Setting | Default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
|
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
|
||||||
|
| `GOVOPLAN_TRUSTED_HOSTS` | empty | Exact API host names accepted by the application. Production-like validation requires an explicit list; narrowly scoped `*.example.org` entries are supported. |
|
||||||
|
| `FORWARDED_ALLOW_IPS` | Uvicorn default | Address or network of the trusted reverse proxy. Never use `*` in production-like deployments. |
|
||||||
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
|
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
|
||||||
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
|
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
|
||||||
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
|
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
|
||||||
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
|
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
|
||||||
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
|
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
|
||||||
|
| `GOVOPLAN_HTTP_HSTS_SECONDS` | `31536000` in production, otherwise `0` | Emitted only for HTTPS requests. Set `0` while rehearsing a deployment that is not yet HTTPS-only. |
|
||||||
|
| `GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES` | `536870912` (512 MiB) | Deployment hard ceiling; file and module APIs apply their own lower limits where appropriate. |
|
||||||
|
|
||||||
|
Interactive password login is enabled with fixed-window limits of 10 failures
|
||||||
|
per normalized identity and 100 failures per direct client over 900 seconds.
|
||||||
|
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
|
||||||
|
when Redis is reachable so replicas share state; a bounded process-local
|
||||||
|
fallback keeps development and Redis outages functional, with per-process
|
||||||
|
enforcement until Redis recovers.
|
||||||
|
|
||||||
|
### Outbound Connector Egress
|
||||||
|
|
||||||
|
| Setting | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS` | `true` in dev/test, otherwise `false` | Deployment-wide decision. Set `true` only when connectors, mail, SMB, or object storage must reach internal addresses. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES` | `16777216` (16 MiB) | Maximum buffered JSON, XML, iCalendar, vCard, catalog, and connector error response. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES` | `536870912` (512 MiB) | Hard upper bound for a single remote file; module upload limits may be lower. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` | empty | Comma-separated exact environment names usable by deployment-owned connector profiles. Tenant/API-managed profiles cannot select process variables, even when a name is listed. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST` | empty | Comma-separated exact absolute CA bundle paths. Mount the same files at the same paths on every API and connector worker. |
|
||||||
|
|
||||||
|
Production-like configuration validation requires the private-network choice to
|
||||||
|
be explicit. HTTP connector downloads are streamed up to the configured bound,
|
||||||
|
and credential-bearing DAV redirects remain confined to their configured
|
||||||
|
origin.
|
||||||
|
|
||||||
|
The urllib, HTTPX/httpcore, SMTP, and IMAP transports resolve, validate, and
|
||||||
|
connect to the same approved address record while retaining the original host
|
||||||
|
for HTTP Host, TLS SNI, and certificate verification. SMB uses an approved
|
||||||
|
numeric connection target in public-only mode. The S3 SDK cannot provide the
|
||||||
|
same peer and redirect guarantee, so S3 connector endpoints fail closed while
|
||||||
|
application-enforced public-only egress is required. Production deployments
|
||||||
|
should still enforce the same decision at their worker/container egress firewall
|
||||||
|
or outbound proxy as a second boundary.
|
||||||
|
|
||||||
|
File connector TLS verification may be disabled only in dev/test. A custom CA
|
||||||
|
bundle must be an existing regular file whose resolved absolute path is listed
|
||||||
|
in `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`. Environment-backed file connector
|
||||||
|
credentials are supported only in deployment-owned connector JSON and require
|
||||||
|
their exact names in `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; UI/API profiles
|
||||||
|
must use encrypted stored credentials or a scoped secret-provider reference.
|
||||||
|
|
||||||
Public URLs are currently supplied by deployment/reverse-proxy configuration and
|
Public URLs are currently supplied by deployment/reverse-proxy configuration and
|
||||||
module settings. Do not hardcode them in core; configuration packages should ask
|
module settings. Do not hardcode them in core; configuration packages should ask
|
||||||
for portal, WebUI, postbox, and notification URLs when they become relevant.
|
for portal, WebUI, postbox, and notification URLs when they become relevant.
|
||||||
|
Uvicorn applies `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS`; keep that value
|
||||||
|
aligned with the reverse proxy and do not expose the application server directly
|
||||||
|
through the same trusted address range.
|
||||||
|
|
||||||
### Module Catalogs, Licenses, And Trust Roots
|
### Module Catalogs, Licenses, And Trust Roots
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,16 @@ set +a
|
|||||||
|
|
||||||
The command reports all known blockers at once. Production-like/self-hosted
|
The command reports all known blockers at once. Production-like/self-hosted
|
||||||
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
|
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
|
||||||
`ENABLED_MODULES`, and `CORS_ORIGINS`. Production rejects SQLite, development
|
`ENABLED_MODULES`, `CORS_ORIGINS`, `GOVOPLAN_TRUSTED_HOSTS`, and a deployment-wide decision for
|
||||||
|
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Production rejects SQLite, development
|
||||||
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
|
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
|
||||||
catalog source is configured.
|
catalog source is configured.
|
||||||
|
|
||||||
|
Connector process-secret names and custom CA files are deployment-owned through
|
||||||
|
the exact, default-empty `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` and
|
||||||
|
`GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`; tenant/API configuration cannot widen
|
||||||
|
either boundary.
|
||||||
|
|
||||||
## Production-Like Dev Stack
|
## Production-Like Dev Stack
|
||||||
|
|
||||||
Use the local production-like wrapper for repeatable rehearsal:
|
Use the local production-like wrapper for repeatable rehearsal:
|
||||||
|
|||||||
@@ -42,3 +42,7 @@ dev = [
|
|||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
"httpx2>=2.5,<3",
|
"httpx2>=2.5,<3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/test_api_smoke.py" = ["E402"]
|
||||||
|
"tests/test_module_system.py" = ["E402"]
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
audit_event="files.connector_profile.updated",
|
audit_event="files.connector_profile.updated",
|
||||||
two_person_approval_required=True,
|
two_person_approval_required=True,
|
||||||
rollback_history_required=True,
|
rollback_history_required=True,
|
||||||
notes="Connector endpoints are UI-manageable, but passwords/tokens remain env or secret refs.",
|
notes="Connector endpoints are UI-manageable. API-managed credentials are encrypted or use scoped secret refs; process-environment references remain deployment-owned.",
|
||||||
),
|
),
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
key="DATABASE_URL",
|
key="DATABASE_URL",
|
||||||
@@ -289,6 +289,174 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
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.",
|
||||||
),
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||||
|
label="Private-network connector access",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||||
|
label="Structured connector response limit",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="A deployment-wide memory-safety limit applied consistently by API and worker processes.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||||
|
label="Connector file-transfer limit",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="A deployment-wide hard ceiling; individual module upload policies may impose lower limits.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||||
|
label="Connector secret environment allowlist",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="destructive",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Exact environment names available only to deployment-owned connector profiles; tenant/API profiles cannot select process variables.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
|
||||||
|
label="Connector CA bundle allowlist",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Exact absolute CA bundle paths approved by the deployment and mounted consistently on every connector worker.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||||
|
label="HTTP request-body limit",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="A deployment-wide hard ceiling; endpoint-specific upload policies may impose lower limits.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_HTTP_HSTS_SECONDS",
|
||||||
|
label="HTTP Strict Transport Security duration",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Deployment-managed browser transport policy; use only after the public service is HTTPS-only.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_ENABLED",
|
||||||
|
label="Interactive login throttling",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Disabling the deployment login throttle weakens protection against password guessing.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
|
||||||
|
label="Login failures per identity",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Shared through Redis when available, with a bounded process-local fallback.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
|
||||||
|
label="Login failures per client",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Counts the direct peer after the deployment's trusted-proxy boundary is applied.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
|
||||||
|
label="Login throttle window",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Fixed counter window shared by identity and direct-client limits.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
|
||||||
|
label="Login throttle Redis retry interval",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="medium",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Controls how quickly a worker retries the distributed counter after falling back locally.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_TRUSTED_HOSTS",
|
||||||
|
label="Trusted HTTP hosts",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Host-header validation is deployment-managed and must cover every public API host.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="FORWARDED_ALLOW_IPS",
|
||||||
|
label="Trusted reverse proxy addresses",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Uvicorn trusts forwarded client and scheme data only from this deployment-managed boundary.",
|
||||||
|
),
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
||||||
label="Module package catalog trusted keys",
|
label="Module package catalog trusted keys",
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
@@ -142,6 +144,7 @@ def validate_runtime_configuration(
|
|||||||
_validate_async_and_auth_settings(env, runtime, collector)
|
_validate_async_and_auth_settings(env, runtime, collector)
|
||||||
_validate_cors_settings(env, runtime, collector)
|
_validate_cors_settings(env, runtime, collector)
|
||||||
_validate_file_storage_settings(env, runtime, collector)
|
_validate_file_storage_settings(env, runtime, collector)
|
||||||
|
_validate_outbound_connector_policy(env, runtime, collector)
|
||||||
_validate_module_catalog_trust(env, runtime, collector)
|
_validate_module_catalog_trust(env, runtime, collector)
|
||||||
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
||||||
|
|
||||||
@@ -224,6 +227,14 @@ def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, co
|
|||||||
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
||||||
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
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.")
|
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
||||||
|
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
|
||||||
|
if runtime.production_like and not trusted_hosts:
|
||||||
|
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
|
||||||
|
elif "*" in trusted_hosts and runtime.production_like:
|
||||||
|
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
|
||||||
|
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
|
||||||
|
if runtime.production_like and "*" in forwarded_allow_ips:
|
||||||
|
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
|
||||||
|
|
||||||
|
|
||||||
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
@@ -249,6 +260,59 @@ def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCol
|
|||||||
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_outbound_connector_policy(
|
||||||
|
env: Mapping[str, str],
|
||||||
|
runtime: _RuntimeProfile,
|
||||||
|
collector: _ConfigIssueCollector,
|
||||||
|
) -> None:
|
||||||
|
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
|
||||||
|
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||||
|
"Private-network connector access must be an explicit deployment decision.",
|
||||||
|
"Set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false for public-only egress, or true when this deployment must reach internal services.",
|
||||||
|
)
|
||||||
|
for key in (
|
||||||
|
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||||
|
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||||
|
"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||||
|
):
|
||||||
|
value = _clean(env.get(key))
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except ValueError:
|
||||||
|
parsed = 0
|
||||||
|
if parsed <= 0:
|
||||||
|
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
|
||||||
|
secret_env_names = [
|
||||||
|
item.strip()
|
||||||
|
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
|
||||||
|
if item.strip()
|
||||||
|
]
|
||||||
|
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||||
|
"Connector secret environment allowlist contains an invalid variable name.",
|
||||||
|
"Use a comma-separated list of exact environment variable names, or leave the setting empty.",
|
||||||
|
)
|
||||||
|
ca_bundle_paths = [
|
||||||
|
item.strip()
|
||||||
|
for item in env.get("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", "").split(",")
|
||||||
|
if item.strip()
|
||||||
|
]
|
||||||
|
if any(not Path(item).is_absolute() for item in ca_bundle_paths):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
|
||||||
|
"Connector CA bundle allowlist contains a non-absolute path.",
|
||||||
|
"Use comma-separated absolute paths mounted on every API and connector worker, or leave the setting empty.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
||||||
if not runtime.production or not catalog_source:
|
if not runtime.production or not catalog_source:
|
||||||
@@ -277,7 +341,28 @@ REDIS_URL=redis://127.0.0.1:6379/0
|
|||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
|
||||||
|
# Deployment-wide connector egress policy. Enable private networks only when
|
||||||
|
# this installation intentionally integrates with internal services.
|
||||||
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||||
|
# Exact names/paths only. Keep empty until a deployment-owned connector needs them.
|
||||||
|
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||||
|
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||||
|
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||||
|
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
|
||||||
|
|
||||||
|
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||||
|
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||||
|
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||||
|
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||||
|
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||||
|
|
||||||
CORS_ORIGINS=https://govoplan.example.org
|
CORS_ORIGINS=https://govoplan.example.org
|
||||||
|
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
|
||||||
|
# Uvicorn reads FORWARDED_ALLOW_IPS when proxy headers are enabled. Keep this
|
||||||
|
# restricted to the actual reverse proxy address or network.
|
||||||
|
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||||
AUTH_COOKIE_SECURE=true
|
AUTH_COOKIE_SECURE=true
|
||||||
AUTH_COOKIE_SAMESITE=lax
|
AUTH_COOKIE_SAMESITE=lax
|
||||||
AUTH_COOKIE_DOMAIN=
|
AUTH_COOKIE_DOMAIN=
|
||||||
@@ -321,8 +406,24 @@ CELERY_ENABLED=true
|
|||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
|
||||||
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||||
|
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||||
|
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||||
|
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||||
|
GOVOPLAN_HTTP_HSTS_SECONDS=0
|
||||||
|
|
||||||
|
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||||
|
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||||
|
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||||
|
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||||
|
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||||
|
|
||||||
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
||||||
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||||
|
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
|
||||||
|
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||||
AUTH_COOKIE_SECURE=false
|
AUTH_COOKIE_SECURE=false
|
||||||
FILE_STORAGE_BACKEND=local
|
FILE_STORAGE_BACKEND=local
|
||||||
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import urllib.request
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
|
from govoplan_core.security.outbound_http import (
|
||||||
|
bounded_response_bytes,
|
||||||
|
build_outbound_http_opener,
|
||||||
|
validate_outbound_http_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class HttpFetchResponse:
|
class HttpFetchResponse:
|
||||||
@@ -40,17 +46,26 @@ def fetch_http(
|
|||||||
label: str = "URL",
|
label: str = "URL",
|
||||||
method: str = "GET",
|
method: str = "GET",
|
||||||
headers: Mapping[str, str] | None = None,
|
headers: Mapping[str, str] | None = None,
|
||||||
|
max_bytes: int | None = None,
|
||||||
) -> HttpFetchResponse:
|
) -> HttpFetchResponse:
|
||||||
|
validated_url = validate_outbound_http_url(url, label=label)
|
||||||
request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S).
|
request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S).
|
||||||
validate_http_url(url, label=label),
|
validated_url,
|
||||||
headers=dict(headers or {}),
|
headers=dict(headers or {}),
|
||||||
method=method,
|
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
|
opener = build_outbound_http_opener(_PolicyRedirectHandler(label=label))
|
||||||
|
with opener.open(request, timeout=timeout) as response: # noqa: S310 - URL and every redirect are policy-validated. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||||
|
response_headers = dict(response.headers.items())
|
||||||
return HttpFetchResponse(
|
return HttpFetchResponse(
|
||||||
status=int(getattr(response, "status", 0)),
|
status=int(getattr(response, "status", 0)),
|
||||||
headers=dict(response.headers.items()),
|
headers=response_headers,
|
||||||
body=response.read(),
|
body=bounded_response_bytes(
|
||||||
|
response,
|
||||||
|
headers=response_headers,
|
||||||
|
max_bytes=max_bytes,
|
||||||
|
label=f"{label} response",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -62,5 +77,29 @@ def fetch_http_text(
|
|||||||
method: str = "GET",
|
method: str = "GET",
|
||||||
headers: Mapping[str, str] | None = None,
|
headers: Mapping[str, str] | None = None,
|
||||||
encoding: str = "utf-8",
|
encoding: str = "utf-8",
|
||||||
|
max_bytes: int | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers).text(encoding)
|
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers, max_bytes=max_bytes).text(encoding)
|
||||||
|
|
||||||
|
|
||||||
|
class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
def __init__(self, *, label: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._label = label
|
||||||
|
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
||||||
|
candidate = validate_outbound_http_url(newurl, label=f"{self._label} redirect")
|
||||||
|
previous = urllib.parse.urlparse(req.full_url)
|
||||||
|
redirected = urllib.parse.urlparse(candidate)
|
||||||
|
if previous.scheme.lower() == "https" and redirected.scheme.lower() != "https":
|
||||||
|
return None
|
||||||
|
new_request = super().redirect_request(req, fp, code, msg, headers, candidate)
|
||||||
|
if new_request is not None and _http_origin(previous) != _http_origin(redirected):
|
||||||
|
for header in ("Authorization", "Proxy-Authorization", "Cookie", "Cookie2"):
|
||||||
|
new_request.remove_header(header)
|
||||||
|
return new_request
|
||||||
|
|
||||||
|
|
||||||
|
def _http_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
return scheme, (parsed.hostname or "").lower(), parsed.port or (443 if scheme == "https" else 80)
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import errno
|
||||||
|
import http.client
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import BinaryIO, Final
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STRUCTURED_RESPONSE_BYTES: Final = 16 * 1024 * 1024
|
||||||
|
DEFAULT_FILE_TRANSFER_BYTES: Final = 512 * 1024 * 1024
|
||||||
|
_READ_CHUNK_BYTES: Final = 64 * 1024
|
||||||
|
_TRUE_VALUES: Final = frozenset({"1", "true", "yes", "on"})
|
||||||
|
_FALSE_VALUES: Final = frozenset({"0", "false", "no", "off"})
|
||||||
|
_IPV4_LIMITED_BROADCAST: Final = ipaddress.IPv4Address("255.255.255.255")
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundHttpError(RuntimeError):
|
||||||
|
"""Base error for deployment-wide outbound HTTP policy failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundHttpBlocked(OutboundHttpError):
|
||||||
|
"""Raised when a URL targets an address forbidden by deployment policy."""
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundResponseTooLarge(OutboundHttpError):
|
||||||
|
"""Raised before a connector can retain an oversized remote response."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OutboundHttpPolicy:
|
||||||
|
allow_private_networks: bool
|
||||||
|
structured_response_bytes: int
|
||||||
|
file_transfer_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
def outbound_http_policy(environ: Mapping[str, str] | None = None) -> OutboundHttpPolicy:
|
||||||
|
env = os.environ if environ is None else environ
|
||||||
|
return OutboundHttpPolicy(
|
||||||
|
allow_private_networks=_private_network_default(env),
|
||||||
|
structured_response_bytes=_positive_int(
|
||||||
|
env.get("GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES"),
|
||||||
|
default=DEFAULT_STRUCTURED_RESPONSE_BYTES,
|
||||||
|
name="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||||
|
),
|
||||||
|
file_transfer_bytes=_positive_int(
|
||||||
|
env.get("GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES"),
|
||||||
|
default=DEFAULT_FILE_TRANSFER_BYTES,
|
||||||
|
name="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_outbound_http_url(
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
label: str = "Connector URL",
|
||||||
|
policy: OutboundHttpPolicy | None = None,
|
||||||
|
) -> str:
|
||||||
|
parsed = urllib.parse.urlparse(str(value).strip())
|
||||||
|
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
|
||||||
|
raise OutboundHttpBlocked(f"{label} must be an absolute HTTP(S) URL")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise OutboundHttpBlocked(f"{label} must not include embedded credentials")
|
||||||
|
try:
|
||||||
|
port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise OutboundHttpBlocked(f"{label} has an invalid port") from exc
|
||||||
|
validate_outbound_host(parsed.hostname, port=port, label=label, policy=policy)
|
||||||
|
return urllib.parse.urlunparse(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_unpinned_sdk_http_url(
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
policy: OutboundHttpPolicy | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Fail closed when an SDK cannot connect to a prevalidated DNS answer."""
|
||||||
|
|
||||||
|
active_policy = policy or outbound_http_policy()
|
||||||
|
validated = validate_outbound_http_url(value, label=label, policy=active_policy)
|
||||||
|
if active_policy.allow_private_networks:
|
||||||
|
return validated
|
||||||
|
raise OutboundHttpBlocked(
|
||||||
|
f"{label} uses an SDK that cannot pin connection peers or revalidate every SDK-managed redirect; "
|
||||||
|
"it is disabled while application-enforced public-only egress is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_outbound_host(
|
||||||
|
hostname: str,
|
||||||
|
*,
|
||||||
|
port: int,
|
||||||
|
label: str = "Connector host",
|
||||||
|
policy: OutboundHttpPolicy | None = None,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
active_policy = policy or outbound_http_policy()
|
||||||
|
records = _resolved_address_records(hostname, port=port, label=label, policy=active_policy)
|
||||||
|
addresses = tuple(dict.fromkeys(str(item[4][0]).split("%", 1)[0] for item in records if item[4]))
|
||||||
|
return addresses
|
||||||
|
|
||||||
|
|
||||||
|
def create_outbound_connection(
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
timeout: float | object | None = None,
|
||||||
|
source_address: tuple[str, int] | None = None,
|
||||||
|
socket_options: Iterable[tuple[object, ...]] | None = None,
|
||||||
|
*,
|
||||||
|
label: str = "Connector host",
|
||||||
|
policy: OutboundHttpPolicy | None = None,
|
||||||
|
) -> socket.socket:
|
||||||
|
"""Resolve, validate, and connect to the exact approved address records.
|
||||||
|
|
||||||
|
Hostname resolution happens exactly once for this connection attempt. The
|
||||||
|
returned socket connects directly to one of those validated sockaddr
|
||||||
|
records, while higher protocol layers retain the original hostname for
|
||||||
|
HTTP Host, TLS SNI, and certificate verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
active_policy = policy or outbound_http_policy()
|
||||||
|
records = _resolved_address_records(hostname, port=port, label=label, policy=active_policy)
|
||||||
|
effective_timeout = None if timeout is socket._GLOBAL_DEFAULT_TIMEOUT else timeout # type: ignore[attr-defined]
|
||||||
|
last_error: OSError | None = None
|
||||||
|
for family, socktype, proto, _canonname, sockaddr in records:
|
||||||
|
sock: socket.socket | None = None
|
||||||
|
try:
|
||||||
|
sock = socket.socket(family, socktype, proto)
|
||||||
|
sock.settimeout(effective_timeout) # type: ignore[arg-type]
|
||||||
|
if source_address is not None:
|
||||||
|
bind_address: tuple[object, ...] = source_address
|
||||||
|
if family == socket.AF_INET6 and len(source_address) == 2:
|
||||||
|
bind_address = (source_address[0], source_address[1], 0, 0)
|
||||||
|
sock.bind(bind_address)
|
||||||
|
for option in socket_options or ():
|
||||||
|
sock.setsockopt(*option)
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno != errno.ENOPROTOOPT:
|
||||||
|
raise
|
||||||
|
sock.connect(sockaddr)
|
||||||
|
return sock
|
||||||
|
except OSError as exc:
|
||||||
|
last_error = exc
|
||||||
|
if sock is not None:
|
||||||
|
sock.close()
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
raise OutboundHttpBlocked(f"{label} hostname did not resolve to a usable address")
|
||||||
|
|
||||||
|
|
||||||
|
def pinned_outbound_hostname(
|
||||||
|
hostname: str,
|
||||||
|
*,
|
||||||
|
port: int,
|
||||||
|
label: str = "Connector host",
|
||||||
|
policy: OutboundHttpPolicy | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return the original allowed host or a public numeric connection target."""
|
||||||
|
|
||||||
|
active_policy = policy or outbound_http_policy()
|
||||||
|
host = str(hostname).strip().rstrip(".")
|
||||||
|
records = _resolved_address_records(host, port=port, label=label, policy=active_policy)
|
||||||
|
if active_policy.allow_private_networks:
|
||||||
|
return host
|
||||||
|
addresses = tuple(dict.fromkeys(str(item[4][0]).split("%", 1)[0] for item in records if item[4]))
|
||||||
|
ipv4 = next((address for address in addresses if ":" not in address), None)
|
||||||
|
return ipv4 or addresses[0]
|
||||||
|
|
||||||
|
|
||||||
|
def build_outbound_http_opener(*handlers: urllib.request.BaseHandler) -> urllib.request.OpenerDirector:
|
||||||
|
"""Build a proxy-free urllib opener whose sockets use approved addresses."""
|
||||||
|
|
||||||
|
return urllib.request.build_opener(
|
||||||
|
urllib.request.ProxyHandler({}),
|
||||||
|
_OutboundHTTPHandler(),
|
||||||
|
_OutboundHTTPSHandler(),
|
||||||
|
*handlers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def response_limit(kind: str, *, policy: OutboundHttpPolicy | None = None) -> int:
|
||||||
|
active_policy = policy or outbound_http_policy()
|
||||||
|
if kind == "structured":
|
||||||
|
return active_policy.structured_response_bytes
|
||||||
|
if kind == "file":
|
||||||
|
return active_policy.file_transfer_bytes
|
||||||
|
raise ValueError("Response kind must be 'structured' or 'file'")
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_response_bytes(
|
||||||
|
stream: BinaryIO,
|
||||||
|
*,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
max_bytes: int | None = None,
|
||||||
|
kind: str = "structured",
|
||||||
|
label: str = "Connector response",
|
||||||
|
) -> bytes:
|
||||||
|
configured_limit = response_limit(kind)
|
||||||
|
limit = configured_limit if max_bytes is None else min(int(max_bytes), configured_limit)
|
||||||
|
if limit <= 0:
|
||||||
|
raise ValueError("max_bytes must be positive")
|
||||||
|
declared_size = _content_length(headers or {})
|
||||||
|
if declared_size is not None and declared_size > limit:
|
||||||
|
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||||
|
body = bytearray()
|
||||||
|
while len(body) <= limit:
|
||||||
|
chunk = stream.read(min(_READ_CHUNK_BYTES, limit + 1 - len(body)))
|
||||||
|
if not chunk:
|
||||||
|
return bytes(body)
|
||||||
|
body.extend(chunk)
|
||||||
|
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_chunks_bytes(
|
||||||
|
chunks: Iterable[bytes],
|
||||||
|
*,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
max_bytes: int | None = None,
|
||||||
|
kind: str = "structured",
|
||||||
|
label: str = "Connector response",
|
||||||
|
) -> bytes:
|
||||||
|
configured_limit = response_limit(kind)
|
||||||
|
limit = configured_limit if max_bytes is None else min(int(max_bytes), configured_limit)
|
||||||
|
if limit <= 0:
|
||||||
|
raise ValueError("max_bytes must be positive")
|
||||||
|
declared_size = _content_length(headers or {})
|
||||||
|
if declared_size is not None and declared_size > limit:
|
||||||
|
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||||
|
body = bytearray()
|
||||||
|
for chunk in chunks:
|
||||||
|
if len(chunk) > limit - len(body):
|
||||||
|
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||||
|
body.extend(chunk)
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _private_network_default(environ: Mapping[str, str]) -> bool:
|
||||||
|
configured = environ.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")
|
||||||
|
if configured is not None and configured.strip():
|
||||||
|
value = configured.strip().lower()
|
||||||
|
if value in _TRUE_VALUES:
|
||||||
|
return True
|
||||||
|
if value in _FALSE_VALUES:
|
||||||
|
return False
|
||||||
|
raise ValueError("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS must be true or false")
|
||||||
|
return environ.get("APP_ENV", "dev").strip().lower() in {"dev", "development", "test"}
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_int(value: str | None, *, default: int, name: str) -> int:
|
||||||
|
if value is None or not value.strip():
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{name} must be a positive integer") from exc
|
||||||
|
if parsed <= 0:
|
||||||
|
raise ValueError(f"{name} must be a positive integer")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _content_length(headers: Mapping[str, str]) -> int | None:
|
||||||
|
value = next((item for key, item in headers.items() if key.casefold() == "content-length"), None)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return parsed if parsed >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_public_address(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(value).is_global
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_forbidden_special_address(value: str) -> bool:
|
||||||
|
"""Keep connector access away from host-local and non-unicast address space.
|
||||||
|
|
||||||
|
Enabling private-network connectors deliberately permits internal and
|
||||||
|
loopback destinations, but it must not expose link-local metadata services
|
||||||
|
or addresses that cannot identify a single remote peer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(value)
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
mapped = getattr(address, "ipv4_mapped", None)
|
||||||
|
if mapped is not None:
|
||||||
|
address = mapped
|
||||||
|
return (
|
||||||
|
address == _IPV4_LIMITED_BROADCAST
|
||||||
|
or address.is_link_local
|
||||||
|
or address.is_multicast
|
||||||
|
or address.is_unspecified
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_address_records(
|
||||||
|
hostname: str,
|
||||||
|
*,
|
||||||
|
port: int,
|
||||||
|
label: str,
|
||||||
|
policy: OutboundHttpPolicy,
|
||||||
|
) -> tuple[tuple[int, int, int, str, tuple[object, ...]], ...]:
|
||||||
|
host = str(hostname).strip().rstrip(".")
|
||||||
|
if not host:
|
||||||
|
raise OutboundHttpBlocked(f"{label} must include a hostname")
|
||||||
|
if not 1 <= int(port) <= 65535:
|
||||||
|
raise OutboundHttpBlocked(f"{label} has an invalid port")
|
||||||
|
try:
|
||||||
|
results = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
raise OutboundHttpBlocked(f"{label} hostname could not be resolved") from exc
|
||||||
|
records = tuple(results)
|
||||||
|
if not records:
|
||||||
|
raise OutboundHttpBlocked(f"{label} hostname did not resolve to an address")
|
||||||
|
addresses = tuple(str(item[4][0]).split("%", 1)[0] for item in records if item[4])
|
||||||
|
if not addresses:
|
||||||
|
raise OutboundHttpBlocked(f"{label} hostname did not resolve to an address")
|
||||||
|
if any(_is_forbidden_special_address(address) for address in addresses):
|
||||||
|
raise OutboundHttpBlocked(f"{label} resolves to a forbidden special-purpose network")
|
||||||
|
if not policy.allow_private_networks and any(not _is_public_address(address) for address in addresses):
|
||||||
|
raise OutboundHttpBlocked(
|
||||||
|
f"{label} resolves to a non-public network; "
|
||||||
|
"set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true for this deployment to permit it"
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _create_outbound_connection_compat(
|
||||||
|
address: tuple[str, int],
|
||||||
|
timeout: float | object | None = socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore[attr-defined]
|
||||||
|
source_address: tuple[str, int] | None = None,
|
||||||
|
) -> socket.socket:
|
||||||
|
return create_outbound_connection(
|
||||||
|
address[0],
|
||||||
|
address[1],
|
||||||
|
timeout=timeout,
|
||||||
|
source_address=source_address,
|
||||||
|
label="Outbound HTTP connection",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundHTTPConnection(http.client.HTTPConnection):
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
super().__init__(*args, **kwargs) # type: ignore[arg-type]
|
||||||
|
self._create_connection = _create_outbound_connection_compat
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundHTTPSConnection(http.client.HTTPSConnection):
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
super().__init__(*args, **kwargs) # type: ignore[arg-type]
|
||||||
|
self._create_connection = _create_outbound_connection_compat
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundHTTPHandler(urllib.request.HTTPHandler):
|
||||||
|
def http_open(self, req): # type: ignore[no-untyped-def]
|
||||||
|
return self.do_open(_OutboundHTTPConnection, req)
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundHTTPSHandler(urllib.request.HTTPSHandler):
|
||||||
|
def https_open(self, req): # type: ignore[no-untyped-def]
|
||||||
|
return self.do_open(_OutboundHTTPSConnection, req, context=self._context)
|
||||||
@@ -9,14 +9,21 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, FastAPI, Request
|
from fastapi import APIRouter, FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||||
|
|
||||||
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
|
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
|
||||||
|
from govoplan_core.core.install_config import validate_runtime_configuration
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.db.query_metrics import collect_query_metrics
|
from govoplan_core.db.query_metrics import collect_query_metrics
|
||||||
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
||||||
|
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
|
||||||
|
|
||||||
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
||||||
logger = logging.getLogger("govoplan.request")
|
logger = logging.getLogger("govoplan.request")
|
||||||
|
_CONTENT_SECURITY_POLICY = "base-uri 'self'; object-src 'none'; frame-ancestors 'none'"
|
||||||
|
_PRODUCTION_LIKE_ENVIRONMENTS = frozenset(
|
||||||
|
{"prod", "production", "self-hosted", "staging", "production-like", "production-like-dev"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _slow_request_threshold_ms() -> float:
|
def _slow_request_threshold_ms() -> float:
|
||||||
@@ -27,6 +34,49 @@ def _slow_request_threshold_ms() -> float:
|
|||||||
return 500.0
|
return 500.0
|
||||||
|
|
||||||
|
|
||||||
|
def _hsts_seconds() -> int:
|
||||||
|
default = "31536000" if os.getenv("APP_ENV", "dev").strip().lower() in {"prod", "production"} else "0"
|
||||||
|
raw = os.getenv("GOVOPLAN_HTTP_HSTS_SECONDS", default).strip()
|
||||||
|
try:
|
||||||
|
return max(0, int(raw))
|
||||||
|
except ValueError:
|
||||||
|
return int(default)
|
||||||
|
|
||||||
|
|
||||||
|
def _max_request_body_bytes() -> int:
|
||||||
|
default = 512 * 1024 * 1024
|
||||||
|
raw = os.getenv("GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES", str(default)).strip()
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return value if value > 0 else default
|
||||||
|
|
||||||
|
|
||||||
|
def _trusted_hosts() -> tuple[str, ...]:
|
||||||
|
return tuple(item.strip() for item in os.getenv("GOVOPLAN_TRUSTED_HOSTS", "").split(",") if item.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_production_startup() -> None:
|
||||||
|
app_env = os.getenv("APP_ENV", "").strip().lower().replace("_", "-")
|
||||||
|
install_profile = os.getenv("GOVOPLAN_INSTALL_PROFILE", "").strip().lower().replace("_", "-")
|
||||||
|
if app_env not in _PRODUCTION_LIKE_ENVIRONMENTS and install_profile not in _PRODUCTION_LIKE_ENVIRONMENTS:
|
||||||
|
return
|
||||||
|
validation = validate_runtime_configuration()
|
||||||
|
if validation.errors:
|
||||||
|
raise RuntimeError(validation.to_text())
|
||||||
|
throttle_enabled = os.getenv("AUTH_LOGIN_THROTTLE_ENABLED", "true").strip().lower() not in {
|
||||||
|
"0",
|
||||||
|
"false",
|
||||||
|
"no",
|
||||||
|
"off",
|
||||||
|
}
|
||||||
|
if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
|
||||||
|
logger.warning(
|
||||||
|
"Redis is not configured; login throttling is process-local and will not coordinate horizontally"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_govoplan_app(
|
def create_govoplan_app(
|
||||||
*,
|
*,
|
||||||
title: str,
|
title: str,
|
||||||
@@ -37,9 +87,27 @@ def create_govoplan_app(
|
|||||||
cors_origins: Iterable[str] = (),
|
cors_origins: Iterable[str] = (),
|
||||||
health_payload: dict[str, Any] | None = None,
|
health_payload: dict[str, Any] | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
|
_validate_production_startup()
|
||||||
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
||||||
|
app.add_middleware(RequestBodyLimitMiddleware, max_bytes=_max_request_body_bytes())
|
||||||
|
trusted_hosts = _trusted_hosts()
|
||||||
|
if trusted_hosts:
|
||||||
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(trusted_hosts))
|
||||||
app.state.govoplan_registry = registry
|
app.state.govoplan_registry = registry
|
||||||
slow_request_threshold_ms = _slow_request_threshold_ms()
|
slow_request_threshold_ms = _slow_request_threshold_ms()
|
||||||
|
hsts_seconds = _hsts_seconds()
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def security_response_headers(request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||||
|
response.headers.setdefault("Content-Security-Policy", _CONTENT_SECURITY_POLICY)
|
||||||
|
if hsts_seconds and request.url.scheme == "https":
|
||||||
|
response.headers.setdefault("Strict-Transport-Security", f"max-age={hsts_seconds}")
|
||||||
|
return response
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def request_correlation_context(request: Request, call_next):
|
async def request_correlation_context(request: Request, call_next):
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
|
class _RequestBodyTooLarge(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RequestBodyLimitMiddleware:
|
||||||
|
def __init__(self, app: Any, *, max_bytes: int) -> None:
|
||||||
|
if max_bytes <= 0:
|
||||||
|
raise ValueError("max_bytes must be positive")
|
||||||
|
self.app = app
|
||||||
|
self.max_bytes = max_bytes
|
||||||
|
|
||||||
|
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||||
|
if scope.get("type") != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
declared = _content_length(scope.get("headers", ()))
|
||||||
|
if declared is not None and declared > self.max_bytes:
|
||||||
|
await self._reject(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
received = 0
|
||||||
|
response_started = False
|
||||||
|
|
||||||
|
async def limited_receive() -> dict[str, Any]:
|
||||||
|
nonlocal received
|
||||||
|
message = await receive()
|
||||||
|
if message.get("type") == "http.request":
|
||||||
|
received += len(message.get("body", b""))
|
||||||
|
if received > self.max_bytes:
|
||||||
|
raise _RequestBodyTooLarge
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def tracked_send(message: dict[str, Any]) -> None:
|
||||||
|
nonlocal response_started
|
||||||
|
if message.get("type") == "http.response.start":
|
||||||
|
response_started = True
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.app(scope, limited_receive, tracked_send)
|
||||||
|
except _RequestBodyTooLarge:
|
||||||
|
if response_started:
|
||||||
|
raise
|
||||||
|
await self._reject(scope, receive, send)
|
||||||
|
|
||||||
|
async def _reject(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||||
|
response = JSONResponse(
|
||||||
|
{"detail": f"Request body exceeds the deployment limit of {self.max_bytes} bytes"},
|
||||||
|
status_code=413,
|
||||||
|
)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
def _content_length(headers: Iterable[tuple[bytes, bytes]]) -> int | None:
|
||||||
|
for key, value in headers:
|
||||||
|
if bytes(key).lower() != b"content-length":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = int(bytes(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return parsed if parsed >= 0 else None
|
||||||
|
return None
|
||||||
@@ -47,6 +47,27 @@ class Settings(BaseSettings):
|
|||||||
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
|
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
|
||||||
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
||||||
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
||||||
|
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
||||||
|
auth_login_throttle_identity_limit: int = Field(
|
||||||
|
default=10,
|
||||||
|
ge=1,
|
||||||
|
alias="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
|
||||||
|
)
|
||||||
|
auth_login_throttle_client_limit: int = Field(
|
||||||
|
default=100,
|
||||||
|
ge=1,
|
||||||
|
alias="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
|
||||||
|
)
|
||||||
|
auth_login_throttle_window_seconds: int = Field(
|
||||||
|
default=15 * 60,
|
||||||
|
ge=1,
|
||||||
|
alias="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
|
||||||
|
)
|
||||||
|
auth_login_throttle_redis_retry_seconds: int = Field(
|
||||||
|
default=30,
|
||||||
|
ge=1,
|
||||||
|
alias="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
|
||||||
|
)
|
||||||
|
|
||||||
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
||||||
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
|
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
|
||||||
|
|||||||
+33
-25
@@ -1811,12 +1811,21 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
tenant_id = str(login["tenant"]["id"])
|
tenant_id = str(login["tenant"]["id"])
|
||||||
env_keys = [
|
env_keys = [
|
||||||
"GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON",
|
"GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON",
|
||||||
|
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||||
"GOVOPLAN_TEST_SEAFILE_PASSWORD",
|
"GOVOPLAN_TEST_SEAFILE_PASSWORD",
|
||||||
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
|
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
|
||||||
"GOVOPLAN_TEST_SMB_PASSWORD",
|
"GOVOPLAN_TEST_SMB_PASSWORD",
|
||||||
]
|
]
|
||||||
previous_env = {key: os.environ.get(key) for key in env_keys}
|
previous_env = {key: os.environ.get(key) for key in env_keys}
|
||||||
try:
|
try:
|
||||||
|
os.environ["GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST"] = ",".join(
|
||||||
|
(
|
||||||
|
"GOVOPLAN_TEST_SEAFILE_PASSWORD",
|
||||||
|
"GOVOPLAN_TEST_WEBDAV_PASSWORD",
|
||||||
|
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
|
||||||
|
"GOVOPLAN_TEST_SMB_PASSWORD",
|
||||||
|
)
|
||||||
|
)
|
||||||
os.environ["GOVOPLAN_TEST_SEAFILE_PASSWORD"] = "super-secret-seafile"
|
os.environ["GOVOPLAN_TEST_SEAFILE_PASSWORD"] = "super-secret-seafile"
|
||||||
os.environ["GOVOPLAN_TEST_NEXTCLOUD_TOKEN"] = "super-secret-nextcloud"
|
os.environ["GOVOPLAN_TEST_NEXTCLOUD_TOKEN"] = "super-secret-nextcloud"
|
||||||
os.environ["GOVOPLAN_TEST_SMB_PASSWORD"] = "super-secret-smb"
|
os.environ["GOVOPLAN_TEST_SMB_PASSWORD"] = "super-secret-smb"
|
||||||
@@ -2203,36 +2212,37 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertFalse(deleted_space.json()["is_active"])
|
self.assertFalse(deleted_space.json()["is_active"])
|
||||||
self.assertIsNotNone(deleted_space.json()["deleted_at"])
|
self.assertIsNotNone(deleted_space.json()["deleted_at"])
|
||||||
|
|
||||||
import httpx
|
from govoplan_files.backend.storage.http_client import ConnectorHttpResponse
|
||||||
|
|
||||||
def seafile_request(method: str, url: str, **kwargs):
|
def seafile_request(method: str, url: str, **kwargs):
|
||||||
request = httpx.Request(method, url)
|
|
||||||
params = kwargs.get("params") or {}
|
params = kwargs.get("params") or {}
|
||||||
if url == "http://127.0.0.1:9082/api2/auth-token/":
|
if url == "http://127.0.0.1:9082/api2/auth-token/":
|
||||||
return httpx.Response(200, json={"token": "token-1"}, request=request)
|
return ConnectorHttpResponse(200, {}, b'{"token":"token-1"}')
|
||||||
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/detail/":
|
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/detail/":
|
||||||
self.assertEqual(params.get("p"), "/reports/summary.txt")
|
self.assertEqual(params.get("p"), "/reports/summary.txt")
|
||||||
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Token token-1")
|
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Token token-1")
|
||||||
return httpx.Response(
|
return ConnectorHttpResponse(
|
||||||
200,
|
200,
|
||||||
json={
|
{},
|
||||||
"id": "file-1",
|
json.dumps(
|
||||||
"name": "summary.txt",
|
{
|
||||||
"size": 14,
|
"id": "file-1",
|
||||||
"type": "file",
|
"name": "summary.txt",
|
||||||
"mtime": 1783425600,
|
"size": 14,
|
||||||
"permission": "r",
|
"type": "file",
|
||||||
},
|
"mtime": 1783425600,
|
||||||
request=request,
|
"permission": "r",
|
||||||
|
}
|
||||||
|
).encode(),
|
||||||
)
|
)
|
||||||
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/":
|
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/":
|
||||||
self.assertEqual(params, {"p": "/reports/summary.txt", "reuse": "1"})
|
self.assertEqual(params, {"p": "/reports/summary.txt", "reuse": "1"})
|
||||||
return httpx.Response(200, json="https://download.example.invalid/summary.txt", request=request)
|
return ConnectorHttpResponse(200, {}, b'"https://download.example.invalid/summary.txt"')
|
||||||
if url == "https://download.example.invalid/summary.txt":
|
if url == "https://download.example.invalid/summary.txt":
|
||||||
return httpx.Response(200, content=b"summary report", headers={"content-type": "text/plain"}, request=request)
|
return ConnectorHttpResponse(200, {"content-type": "text/plain"}, b"summary report")
|
||||||
return httpx.Response(404, request=request)
|
return ConnectorHttpResponse(404, {}, b"")
|
||||||
|
|
||||||
with patch("govoplan_files.backend.storage.connector_browse.httpx.request", side_effect=seafile_request), patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=seafile_request):
|
with patch("govoplan_files.backend.storage.connector_browse.request_connector_bytes", side_effect=seafile_request), patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=seafile_request):
|
||||||
imported = self.client.post(
|
imported = self.client.post(
|
||||||
"/api/v1/files/connectors/profiles/seafile-dev/import",
|
"/api/v1/files/connectors/profiles/seafile-dev/import",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -2259,18 +2269,16 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
webdav_payload = {"content": b"nextcloud notice", "etag": "\"nextcloud-etag-2\""}
|
webdav_payload = {"content": b"nextcloud notice", "etag": "\"nextcloud-etag-2\""}
|
||||||
|
|
||||||
def webdav_request(method: str, url: str, **kwargs):
|
def webdav_request(method: str, url: str, **kwargs):
|
||||||
request = httpx.Request(method, url)
|
|
||||||
self.assertEqual(method, "GET")
|
self.assertEqual(method, "GET")
|
||||||
self.assertEqual(url, "http://127.0.0.1:9081/Shared/notice.txt")
|
self.assertEqual(url, "http://127.0.0.1:9081/Shared/notice.txt")
|
||||||
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Bearer super-secret-nextcloud")
|
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Bearer super-secret-nextcloud")
|
||||||
return httpx.Response(
|
return ConnectorHttpResponse(
|
||||||
200,
|
200,
|
||||||
content=webdav_payload["content"],
|
{"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))},
|
||||||
headers={"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))},
|
webdav_payload["content"],
|
||||||
request=request,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request):
|
with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
|
||||||
nextcloud_import = self.client.post(
|
nextcloud_import = self.client.post(
|
||||||
"/api/v1/files/connectors/profiles/user-nextcloud/import",
|
"/api/v1/files/connectors/profiles/user-nextcloud/import",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -2292,7 +2300,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
webdav_payload["content"] = b"nextcloud notice updated"
|
webdav_payload["content"] = b"nextcloud notice updated"
|
||||||
webdav_payload["etag"] = "\"nextcloud-etag-3\""
|
webdav_payload["etag"] = "\"nextcloud-etag-3\""
|
||||||
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request):
|
with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
|
||||||
nextcloud_sync = self.client.post(
|
nextcloud_sync = self.client.post(
|
||||||
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
|
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -2314,7 +2322,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(synced["file"]["source_revision"], "\"nextcloud-etag-3\"")
|
self.assertEqual(synced["file"]["source_revision"], "\"nextcloud-etag-3\"")
|
||||||
self.assertEqual(synced["file"]["size_bytes"], len(webdav_payload["content"]))
|
self.assertEqual(synced["file"]["size_bytes"], len(webdav_payload["content"]))
|
||||||
|
|
||||||
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request):
|
with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
|
||||||
nextcloud_sync_unchanged = self.client.post(
|
nextcloud_sync_unchanged = self.client.post(
|
||||||
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
|
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from govoplan_core.audit.logging import audit_event, audit_operation_context
|
from govoplan_core.audit.logging import audit_event, audit_operation_context
|
||||||
@@ -142,6 +143,75 @@ class CoreEventTests(unittest.TestCase):
|
|||||||
cors_origins=("*",),
|
cors_origins=("*",),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_app_sets_safe_default_browser_headers_and_https_hsts(self) -> None:
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"APP_ENV": "test", "GOVOPLAN_HTTP_HSTS_SECONDS": "86400"},
|
||||||
|
):
|
||||||
|
app = create_govoplan_app(
|
||||||
|
title="security header test",
|
||||||
|
version="test",
|
||||||
|
registry=PlatformRegistry(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(app, base_url="https://govoplan.example.test") as client:
|
||||||
|
response = client.get("/health")
|
||||||
|
|
||||||
|
self.assertEqual("nosniff", response.headers["X-Content-Type-Options"])
|
||||||
|
self.assertEqual("DENY", response.headers["X-Frame-Options"])
|
||||||
|
self.assertEqual("strict-origin-when-cross-origin", response.headers["Referrer-Policy"])
|
||||||
|
self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"])
|
||||||
|
self.assertEqual("max-age=86400", response.headers["Strict-Transport-Security"])
|
||||||
|
|
||||||
|
def test_production_app_startup_rejects_an_unvalidated_environment(self) -> None:
|
||||||
|
with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex(
|
||||||
|
RuntimeError,
|
||||||
|
"GovOPlaN configuration validation: FAILED",
|
||||||
|
):
|
||||||
|
create_govoplan_app(
|
||||||
|
title="unsafe production test",
|
||||||
|
version="test",
|
||||||
|
registry=PlatformRegistry(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_app_rejects_request_bodies_above_deployment_limit(self) -> None:
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/body")
|
||||||
|
async def body(request: Request):
|
||||||
|
return {"size": len(await request.body())}
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES": "10"}):
|
||||||
|
app = create_govoplan_app(
|
||||||
|
title="request body limit test",
|
||||||
|
version="test",
|
||||||
|
registry=PlatformRegistry(),
|
||||||
|
api_router=router,
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post("/body", content=b"12345678901")
|
||||||
|
streamed = client.post("/body", content=(chunk for chunk in (b"123456", b"78901")))
|
||||||
|
|
||||||
|
self.assertEqual(413, response.status_code, response.text)
|
||||||
|
self.assertIn("10 bytes", response.json()["detail"])
|
||||||
|
self.assertEqual(413, streamed.status_code, streamed.text)
|
||||||
|
|
||||||
|
def test_app_enforces_configured_trusted_hosts(self) -> None:
|
||||||
|
with patch.dict("os.environ", {"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.test,*.internal.test"}):
|
||||||
|
app = create_govoplan_app(
|
||||||
|
title="trusted host test",
|
||||||
|
version="test",
|
||||||
|
registry=PlatformRegistry(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(app, base_url="https://govoplan.example.test") as client:
|
||||||
|
allowed = client.get("/health")
|
||||||
|
rejected = client.get("https://attacker.example.test/health")
|
||||||
|
|
||||||
|
self.assertEqual(200, allowed.status_code, allowed.text)
|
||||||
|
self.assertEqual(400, rejected.status_code, rejected.text)
|
||||||
|
|
||||||
def test_audit_event_persists_trace_details_from_event_context(self) -> None:
|
def test_audit_event_persists_trace_details_from_event_context(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
|
||||||
try:
|
try:
|
||||||
|
|||||||
+160
-1
@@ -1,8 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_core.security.http_fetch import is_http_url, validate_http_url
|
from govoplan_core.security.http_fetch import _PolicyRedirectHandler, is_http_url, validate_http_url
|
||||||
|
from govoplan_core.security.outbound_http import (
|
||||||
|
DEFAULT_FILE_TRANSFER_BYTES,
|
||||||
|
DEFAULT_STRUCTURED_RESPONSE_BYTES,
|
||||||
|
OutboundHttpBlocked,
|
||||||
|
OutboundResponseTooLarge,
|
||||||
|
bounded_chunks_bytes,
|
||||||
|
bounded_response_bytes,
|
||||||
|
create_outbound_connection,
|
||||||
|
outbound_http_policy,
|
||||||
|
validate_outbound_http_url,
|
||||||
|
validate_unpinned_sdk_http_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HttpFetchTests(unittest.TestCase):
|
class HttpFetchTests(unittest.TestCase):
|
||||||
@@ -22,6 +36,151 @@ class HttpFetchTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
validate_http_url(value)
|
validate_http_url(value)
|
||||||
|
|
||||||
|
def test_outbound_policy_has_practical_bounded_defaults(self) -> None:
|
||||||
|
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||||
|
self.assertFalse(policy.allow_private_networks)
|
||||||
|
self.assertEqual(DEFAULT_STRUCTURED_RESPONSE_BYTES, policy.structured_response_bytes)
|
||||||
|
self.assertEqual(DEFAULT_FILE_TRANSFER_BYTES, policy.file_transfer_bytes)
|
||||||
|
|
||||||
|
configured = outbound_http_policy(
|
||||||
|
{
|
||||||
|
"APP_ENV": "production",
|
||||||
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true",
|
||||||
|
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES": "1024",
|
||||||
|
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES": "2048",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(configured.allow_private_networks)
|
||||||
|
self.assertEqual(1024, configured.structured_response_bytes)
|
||||||
|
self.assertEqual(2048, configured.file_transfer_bytes)
|
||||||
|
|
||||||
|
def test_private_network_policy_is_deployment_wide(self) -> None:
|
||||||
|
denied_policy = outbound_http_policy({"APP_ENV": "production"})
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||||
|
), self.assertRaisesRegex(OutboundHttpBlocked, "non-public network"):
|
||||||
|
validate_outbound_http_url("https://connector.example.test/path", policy=denied_policy)
|
||||||
|
|
||||||
|
allowed_policy = outbound_http_policy(
|
||||||
|
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
"https://127.0.0.1/path",
|
||||||
|
validate_outbound_http_url("https://127.0.0.1/path", policy=allowed_policy),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_private_network_policy_still_blocks_non_peer_and_metadata_addresses(self) -> None:
|
||||||
|
policy = outbound_http_policy(
|
||||||
|
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
|
||||||
|
)
|
||||||
|
for address in (
|
||||||
|
"0.0.0.0",
|
||||||
|
"169.254.169.254",
|
||||||
|
"224.0.0.1",
|
||||||
|
"255.255.255.255",
|
||||||
|
"::",
|
||||||
|
"::ffff:255.255.255.255",
|
||||||
|
"fe80::1",
|
||||||
|
"ff02::1",
|
||||||
|
):
|
||||||
|
with self.subTest(address=address), patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
return_value=[(2, 1, 6, "", (address, 443))],
|
||||||
|
), self.assertRaisesRegex(OutboundHttpBlocked, "forbidden special-purpose network"):
|
||||||
|
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
|
||||||
|
|
||||||
|
def test_outbound_policy_rejects_mixed_public_and_private_dns_answers(self) -> None:
|
||||||
|
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
return_value=[
|
||||||
|
(2, 1, 6, "", ("93.184.216.34", 443)),
|
||||||
|
(2, 1, 6, "", ("10.0.0.4", 443)),
|
||||||
|
],
|
||||||
|
), self.assertRaises(OutboundHttpBlocked):
|
||||||
|
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
|
||||||
|
|
||||||
|
def test_bounded_response_rejects_declared_and_streamed_oversize_payloads(self) -> None:
|
||||||
|
with self.assertRaises(OutboundResponseTooLarge):
|
||||||
|
bounded_response_bytes(io.BytesIO(b"small"), headers={"Content-Length": "20"}, max_bytes=10)
|
||||||
|
with self.assertRaises(OutboundResponseTooLarge):
|
||||||
|
bounded_response_bytes(io.BytesIO(b"eleven-byte"), max_bytes=10)
|
||||||
|
self.assertEqual(b"ten-bytes!", bounded_response_bytes(io.BytesIO(b"ten-bytes!"), max_bytes=10))
|
||||||
|
with patch.dict("os.environ", {"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES": "5"}), self.assertRaises(
|
||||||
|
OutboundResponseTooLarge
|
||||||
|
):
|
||||||
|
bounded_response_bytes(io.BytesIO(b"123456"), max_bytes=10)
|
||||||
|
|
||||||
|
def test_bounded_chunks_rejects_an_oversized_chunk_before_retaining_it(self) -> None:
|
||||||
|
with self.assertRaises(OutboundResponseTooLarge):
|
||||||
|
bounded_chunks_bytes((b"one-large-chunk",), max_bytes=8)
|
||||||
|
|
||||||
|
def test_connection_time_resolution_blocks_dns_rebinding_before_socket_open(self) -> None:
|
||||||
|
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||||
|
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
|
||||||
|
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
side_effect=(public, private),
|
||||||
|
) as resolver, patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory:
|
||||||
|
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
|
||||||
|
with self.assertRaisesRegex(OutboundHttpBlocked, "non-public network"):
|
||||||
|
create_outbound_connection("connector.example.test", 443, policy=policy)
|
||||||
|
|
||||||
|
self.assertEqual(2, resolver.call_count)
|
||||||
|
socket_factory.assert_not_called()
|
||||||
|
|
||||||
|
def test_unpinned_sdk_endpoints_fail_closed_for_dns_hosts(self) -> None:
|
||||||
|
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
return_value=[(2, 1, 6, "", ("93.184.216.34", 443))],
|
||||||
|
), self.assertRaisesRegex(OutboundHttpBlocked, "cannot pin"):
|
||||||
|
validate_unpinned_sdk_http_url(
|
||||||
|
"https://objects.example.test",
|
||||||
|
label="S3 endpoint",
|
||||||
|
policy=policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_redirects_strip_credentials_cross_origin_and_reject_https_downgrades(self) -> None:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
request = urllib.request.Request(
|
||||||
|
"https://catalog.example.test/releases",
|
||||||
|
headers={"Authorization": "Bearer secret", "X-Request-ID": "request-1"},
|
||||||
|
)
|
||||||
|
handler = _PolicyRedirectHandler(label="Catalog URL")
|
||||||
|
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||||
|
):
|
||||||
|
redirected = handler.redirect_request(
|
||||||
|
request,
|
||||||
|
None,
|
||||||
|
302,
|
||||||
|
"Found",
|
||||||
|
{},
|
||||||
|
"https://cdn.example.test/releases",
|
||||||
|
)
|
||||||
|
downgrade = handler.redirect_request(
|
||||||
|
request,
|
||||||
|
None,
|
||||||
|
302,
|
||||||
|
"Found",
|
||||||
|
{},
|
||||||
|
"http://catalog.example.test/releases",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(redirected)
|
||||||
|
self.assertIsNone(redirected.get_header("Authorization"))
|
||||||
|
self.assertEqual("request-1", redirected.get_header("X-request-id"))
|
||||||
|
self.assertIsNone(downgrade)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
"CELERY_ENABLED": "true",
|
"CELERY_ENABLED": "true",
|
||||||
"REDIS_URL": "redis://redis.example.internal:6379/0",
|
"REDIS_URL": "redis://redis.example.internal:6379/0",
|
||||||
"CORS_ORIGINS": "https://govoplan.example.org",
|
"CORS_ORIGINS": "https://govoplan.example.org",
|
||||||
|
"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.org",
|
||||||
"AUTH_COOKIE_SECURE": "true",
|
"AUTH_COOKIE_SECURE": "true",
|
||||||
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
|
||||||
"FILE_STORAGE_BACKEND": "local",
|
"FILE_STORAGE_BACKEND": "local",
|
||||||
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
|
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
|
||||||
},
|
},
|
||||||
@@ -75,6 +77,35 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
self.assertIn("DATABASE_URL", error_keys)
|
self.assertIn("DATABASE_URL", error_keys)
|
||||||
self.assertIn("DEV_BOOTSTRAP_ENABLED", error_keys)
|
self.assertIn("DEV_BOOTSTRAP_ENABLED", error_keys)
|
||||||
|
|
||||||
|
def test_production_validation_rejects_wildcard_host_and_proxy_trust(self) -> None:
|
||||||
|
result = validate_runtime_configuration(
|
||||||
|
{
|
||||||
|
"APP_ENV": "production",
|
||||||
|
"GOVOPLAN_TRUSTED_HOSTS": "*",
|
||||||
|
"FORWARDED_ALLOW_IPS": "*",
|
||||||
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
|
||||||
|
},
|
||||||
|
profile="self-hosted",
|
||||||
|
)
|
||||||
|
|
||||||
|
error_keys = {issue.key for issue in result.errors}
|
||||||
|
self.assertIn("GOVOPLAN_TRUSTED_HOSTS", error_keys)
|
||||||
|
self.assertIn("FORWARDED_ALLOW_IPS", error_keys)
|
||||||
|
|
||||||
|
def test_connector_deployment_allowlists_require_exact_names_and_absolute_paths(self) -> None:
|
||||||
|
result = validate_runtime_configuration(
|
||||||
|
{
|
||||||
|
"APP_ENV": "development",
|
||||||
|
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST": "VALID_SECRET,invalid-name",
|
||||||
|
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST": "relative/connector-ca.pem",
|
||||||
|
},
|
||||||
|
profile="development",
|
||||||
|
)
|
||||||
|
|
||||||
|
error_keys = {issue.key for issue in result.errors}
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", error_keys)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", error_keys)
|
||||||
|
|
||||||
def test_env_templates_surface_install_profiles(self) -> None:
|
def test_env_templates_surface_install_profiles(self) -> None:
|
||||||
self_hosted = env_template(profile="self-hosted")
|
self_hosted = env_template(profile="self-hosted")
|
||||||
production_like = env_template(profile="production-like", generate_secrets=True)
|
production_like = env_template(profile="production-like", generate_secrets=True)
|
||||||
@@ -82,9 +113,21 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
||||||
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
||||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
|
||||||
|
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org", self_hosted)
|
||||||
|
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=31536000", self_hosted)
|
||||||
|
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", self_hosted)
|
||||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
||||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
|
||||||
|
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver", production_like)
|
||||||
|
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=0", production_like)
|
||||||
|
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", production_like)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+15
-21
@@ -6,13 +6,13 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import runpy
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import textwrap
|
|
||||||
import tomllib
|
import tomllib
|
||||||
import unittest
|
import unittest
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
@@ -3079,27 +3079,21 @@ finally:
|
|||||||
self.assertEqual("stable", validation["channel"])
|
self.assertEqual("stable", validation["channel"])
|
||||||
|
|
||||||
def test_release_catalog_generator_reads_manifest_interface_contracts(self) -> None:
|
def test_release_catalog_generator_reads_manifest_interface_contracts(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-release-catalog-generator-", dir=_TEST_ROOT))
|
generator = runpy.run_path(
|
||||||
catalog_path = root / "catalog.json"
|
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "release" / "generate-release-catalog.py"),
|
||||||
|
run_name="govoplan_release_catalog_contract_test",
|
||||||
result = subprocess.run(
|
)
|
||||||
[
|
generated_at = generator["datetime"].now(tz=generator["UTC"])
|
||||||
sys.executable,
|
catalog = generator["_catalog_payload"](
|
||||||
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "generate-release-catalog.py"),
|
version="0.1.9",
|
||||||
"--version",
|
tag="v0.1.9",
|
||||||
"0.1.7",
|
channel="test",
|
||||||
"--catalog-output",
|
sequence=1,
|
||||||
str(catalog_path),
|
generated_at=generated_at,
|
||||||
],
|
expires_at=generated_at + generator["timedelta"](days=1),
|
||||||
cwd=str(Path(__file__).resolve().parents[1]),
|
repository_base="git+ssh://git@example.test/add-ideas",
|
||||||
env={**os.environ.copy(), "GOVOPLAN_CORE_ROOT": str(Path(__file__).resolve().parents[1])},
|
public_base_url="https://example.test",
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
|
||||||
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
||||||
modules = {item["module_id"]: item for item in catalog["modules"]}
|
modules = {item["module_id"]: item for item in catalog["modules"]}
|
||||||
|
|
||||||
self.assertIn({"name": "files.campaign_attachments", "version": "0.1.6"}, modules["files"]["provides_interfaces"])
|
self.assertIn({"name": "files.campaign_attachments", "version": "0.1.6"}, modules["files"]["provides_interfaces"])
|
||||||
|
|||||||
+5
-1
@@ -24,9 +24,13 @@
|
|||||||
"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: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:explorer-tree": "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/explorer-tree.test.js",
|
||||||
|
"test:icon-button": "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/icon-button.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-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",
|
||||||
|
"test:resource-access": "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/resource-access-explanation.test.js",
|
||||||
|
"test:selection-list": "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/selection-list.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
||||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
||||||
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-addresses.git#v0.1.8",
|
|
||||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
|
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
|
||||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
|
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
|
||||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
|
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
|
||||||
@@ -32,7 +31,6 @@
|
|||||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
|
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
|
||||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
|
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
|
||||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
|
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
|
||||||
"@govoplan/notifications-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-notifications.git#v0.1.8",
|
|
||||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
|
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
|
||||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
|
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
|
||||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
|
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ApiSettings } from "../types";
|
||||||
|
import { apiFetch, apiPath } from "./client";
|
||||||
|
import type {
|
||||||
|
AccessDecisionProvenanceItem,
|
||||||
|
ResourceAccessExplanationOptions,
|
||||||
|
ResourceAccessExplanationResponse,
|
||||||
|
ResourceAccessExplanationUser
|
||||||
|
} from "./resourceAccessContracts";
|
||||||
|
export type {
|
||||||
|
AccessDecisionProvenanceItem,
|
||||||
|
ResourceAccessExplanationOptions,
|
||||||
|
ResourceAccessExplanationResponse,
|
||||||
|
ResourceAccessExplanationUser
|
||||||
|
} from "./resourceAccessContracts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the platform's optional resource-access explanation endpoint.
|
||||||
|
* Core owns the transport contract without requiring the Access module that
|
||||||
|
* contributes the endpoint at runtime.
|
||||||
|
*/
|
||||||
|
export function fetchResourceAccessExplanation<
|
||||||
|
TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser,
|
||||||
|
TProvenance extends AccessDecisionProvenanceItem = AccessDecisionProvenanceItem
|
||||||
|
>(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: ResourceAccessExplanationOptions
|
||||||
|
): Promise<ResourceAccessExplanationResponse<TUser, TProvenance>> {
|
||||||
|
return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", {
|
||||||
|
user_id: options.userId,
|
||||||
|
resource_type: options.resourceType,
|
||||||
|
resource_id: options.resourceId,
|
||||||
|
action: options.action,
|
||||||
|
tenant_id: options.tenantId
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type ResourceAccessExplanationUser = {
|
||||||
|
id: string;
|
||||||
|
account_id?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
display_name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccessDecisionProvenanceItem = {
|
||||||
|
kind: string;
|
||||||
|
id?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
tenant_id?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceAccessExplanationResponse<
|
||||||
|
TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser,
|
||||||
|
TProvenance extends AccessDecisionProvenanceItem = AccessDecisionProvenanceItem
|
||||||
|
> = {
|
||||||
|
user: TUser;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string;
|
||||||
|
action: string;
|
||||||
|
provenance: TProvenance[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceAccessExplanationOptions = {
|
||||||
|
userId: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string;
|
||||||
|
action: string;
|
||||||
|
tenantId?: string | null;
|
||||||
|
};
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "secondary" | "ghost" | "danger" };
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
import DisabledActionTooltip from "./DisabledActionTooltip";
|
||||||
|
|
||||||
export default function Button({ variant = "secondary", className = "", ...props }: Props) {
|
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
return <button className={`btn btn-${variant} ${className}`} {...props} />;
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
disabledReason?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, ...props }: ButtonProps) {
|
||||||
|
const button = <button className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
|
||||||
|
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { i18nMessage } from "../i18n/LanguageContext";import { Palette } from "lucide-react";
|
import { Palette } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, type InputHTMLAttributes } from "react";
|
import { useEffect, useRef, useState, type InputHTMLAttributes } from "react";
|
||||||
|
import useOutsideDismiss from "../hooks/useOutsideDismiss";
|
||||||
|
import { i18nMessage } from "../i18n/LanguageContext";
|
||||||
|
|
||||||
type ColorPickerFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
|
type ColorPickerFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -20,27 +22,6 @@ function normalizeColor(value: string): string {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useOutsideClose(open: boolean, onClose: () => void) {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function handlePointerDown(event: PointerEvent) {
|
|
||||||
if (!ref.current || ref.current.contains(event.target as Node)) return;
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("pointerdown", handlePointerDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [open, onClose]);
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ColorPickerField({
|
export default function ColorPickerField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -51,7 +32,7 @@ export default function ColorPickerField({
|
|||||||
...props
|
...props
|
||||||
}: ColorPickerFieldProps) {
|
}: ColorPickerFieldProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const rootRef = useOutsideClose(open, () => setOpen(false));
|
const rootRef = useOutsideDismiss(open, () => setOpen(false));
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const normalizedValue = normalizeColor(value || "");
|
const normalizedValue = normalizeColor(value || "");
|
||||||
const previewColor = HEX_PATTERN.test(normalizedValue) ? normalizedValue : "transparent";
|
const previewColor = HEX_PATTERN.test(normalizedValue) ? normalizedValue : "transparent";
|
||||||
@@ -102,4 +83,4 @@ export default function ColorPickerField({
|
|||||||
}
|
}
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react";
|
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react";
|
import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react";
|
||||||
|
import useOutsideDismiss from "../hooks/useOutsideDismiss";
|
||||||
|
|
||||||
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & {
|
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -48,32 +49,11 @@ function combineDateTime(date: string, time: string): string {
|
|||||||
return `${date || dateString(new Date())}T${time || "00:00"}`;
|
return `${date || dateString(new Date())}T${time || "00:00"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useOutsideClose(open: boolean, onClose: () => void) {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function handlePointerDown(event: PointerEvent) {
|
|
||||||
if (!ref.current || ref.current.contains(event.target as Node)) return;
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("pointerdown", handlePointerDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [open, onClose]);
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", ...props }: BaseProps) {
|
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", ...props }: BaseProps) {
|
||||||
const selectedDate = parseDate(value);
|
const selectedDate = parseDate(value);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
|
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
|
||||||
const rootRef = useOutsideClose(open, () => setOpen(false));
|
const rootRef = useOutsideDismiss(open, () => setOpen(false));
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -228,4 +208,4 @@ export function DateTimeField({ value, onChange, min, max, disabled, className =
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DateField;
|
export default DateField;
|
||||||
|
|||||||
@@ -10,15 +10,13 @@ export type ExplorerTreeNodeContext = {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExplorerTreeProps<T> = {
|
type ExplorerTreeCommonProps<T> = {
|
||||||
nodes: T[];
|
nodes: T[];
|
||||||
getNodeId: (node: T) => string;
|
getNodeId: (node: T) => string;
|
||||||
getNodeLabel: (node: T) => string;
|
getNodeLabel: (node: T) => string;
|
||||||
getNodeChildren: (node: T) => T[];
|
getNodeChildren: (node: T) => T[];
|
||||||
activeId?: string;
|
activeId?: string;
|
||||||
expandedIds: Set<string>;
|
|
||||||
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
|
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||||
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -29,6 +27,7 @@ export type ExplorerTreeProps<T> = {
|
|||||||
nodeButtonBaseClassName?: string;
|
nodeButtonBaseClassName?: string;
|
||||||
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||||
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||||
|
renderNodeActions?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||||
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
||||||
getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined;
|
getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined;
|
||||||
getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
||||||
@@ -41,122 +40,162 @@ export type ExplorerTreeProps<T> = {
|
|||||||
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExplorerTree<T>({
|
type CollapsibleExplorerTreeProps<T> = {
|
||||||
nodes,
|
collapsible?: true;
|
||||||
getNodeId,
|
expandedIds: ReadonlySet<string>;
|
||||||
getNodeLabel,
|
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||||
getNodeChildren,
|
};
|
||||||
activeId = "",
|
|
||||||
expandedIds,
|
|
||||||
onOpen,
|
|
||||||
onToggle,
|
|
||||||
disabled = false,
|
|
||||||
depth = 1,
|
|
||||||
className = "",
|
|
||||||
childrenBaseClassName = "explorer-tree-children file-tree-children",
|
|
||||||
nodeContainerClassName = "",
|
|
||||||
nodeWrapBaseClassName = "explorer-tree-node-wrap file-tree-node-wrap",
|
|
||||||
toggleBaseClassName = "explorer-tree-toggle file-tree-toggle",
|
|
||||||
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
|
|
||||||
renderToggleIcon,
|
|
||||||
renderNodeContent,
|
|
||||||
getNodeWrapClassName,
|
|
||||||
getNodeWrapStyle,
|
|
||||||
getNodeButtonClassName,
|
|
||||||
getNodeDraggable,
|
|
||||||
onContextMenu,
|
|
||||||
onDragStart,
|
|
||||||
onDragEnd,
|
|
||||||
onDragOver,
|
|
||||||
onDragLeave,
|
|
||||||
onDrop
|
|
||||||
}: ExplorerTreeProps<T>) {
|
|
||||||
if (nodes.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
type StaticExplorerTreeProps = {
|
||||||
<div className={[childrenBaseClassName, className].filter(Boolean).join(" ")}>
|
collapsible: false;
|
||||||
{nodes.map((node) => {
|
expandedIds?: never;
|
||||||
const nodeId = getNodeId(node);
|
onToggle?: never;
|
||||||
const children = getNodeChildren(node);
|
};
|
||||||
const hasChildren = children.length > 0;
|
|
||||||
const expanded = expandedIds.has(nodeId);
|
|
||||||
const active = activeId === nodeId;
|
|
||||||
const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled };
|
|
||||||
const draggable = getNodeDraggable?.(node, context) ?? false;
|
|
||||||
|
|
||||||
return (
|
export type ExplorerTreeProps<T> = ExplorerTreeCommonProps<T> & (
|
||||||
<div key={nodeId} className={nodeContainerClassName}>
|
CollapsibleExplorerTreeProps<T> | StaticExplorerTreeProps
|
||||||
<div
|
);
|
||||||
className={[
|
|
||||||
nodeWrapBaseClassName,
|
type SafeNode<T> = {
|
||||||
active ? "is-active" : "",
|
id: string;
|
||||||
getNodeWrapClassName?.(node, context) ?? ""].
|
node: T;
|
||||||
filter(Boolean).join(" ")}
|
};
|
||||||
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
|
||||||
draggable={draggable && !disabled}
|
const EMPTY_EXPANDED_IDS: ReadonlySet<string> = new Set();
|
||||||
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
|
|
||||||
onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined}
|
export default function ExplorerTree<T>(props: ExplorerTreeProps<T>) {
|
||||||
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
|
const {
|
||||||
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
|
nodes,
|
||||||
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
|
getNodeId,
|
||||||
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}>
|
getNodeLabel,
|
||||||
|
getNodeChildren,
|
||||||
<button
|
activeId = "",
|
||||||
type="button"
|
onOpen,
|
||||||
className={toggleBaseClassName}
|
disabled = false,
|
||||||
onClick={(event) => {
|
depth = 1,
|
||||||
event.stopPropagation();
|
className = "",
|
||||||
if (hasChildren) onToggle(node, context);
|
childrenBaseClassName = "explorer-tree-children file-tree-children",
|
||||||
}}
|
nodeContainerClassName = "",
|
||||||
disabled={disabled || !hasChildren}
|
nodeWrapBaseClassName = "explorer-tree-node-wrap file-tree-node-wrap",
|
||||||
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", { value0: expanded ? "i18n:govoplan-core.collapse.9cf188d3" : "i18n:govoplan-core.expand.9869e506", value1: getNodeLabel(node) })}
|
toggleBaseClassName = "explorer-tree-toggle file-tree-toggle",
|
||||||
aria-expanded={hasChildren ? expanded : undefined}>
|
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
|
||||||
|
renderToggleIcon,
|
||||||
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
renderNodeContent,
|
||||||
</button>
|
renderNodeActions,
|
||||||
<button
|
getNodeWrapClassName,
|
||||||
type="button"
|
getNodeWrapStyle,
|
||||||
className={[nodeButtonBaseClassName, getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
getNodeButtonClassName,
|
||||||
onClick={() => onOpen(node, context)}
|
getNodeDraggable,
|
||||||
disabled={disabled}>
|
onContextMenu,
|
||||||
|
onDragStart,
|
||||||
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
onDragEnd,
|
||||||
</button>
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop
|
||||||
|
} = props;
|
||||||
|
const collapsible = props.collapsible !== false;
|
||||||
|
const expandedIds = props.collapsible === false ? EMPTY_EXPANDED_IDS : props.expandedIds;
|
||||||
|
const onToggle = props.collapsible === false ? undefined : props.onToggle;
|
||||||
|
|
||||||
|
function pathSafeNodes(levelNodes: T[], ancestorIds: ReadonlySet<string>): SafeNode<T>[] {
|
||||||
|
const siblingIds = new Set<string>();
|
||||||
|
const safeNodes: SafeNode<T>[] = [];
|
||||||
|
for (const node of levelNodes) {
|
||||||
|
const id = getNodeId(node);
|
||||||
|
if (ancestorIds.has(id) || siblingIds.has(id)) continue;
|
||||||
|
siblingIds.add(id);
|
||||||
|
safeNodes.push({ id, node });
|
||||||
|
}
|
||||||
|
return safeNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLevel(levelNodes: T[], currentDepth: number, ancestorIds: ReadonlySet<string>, root = false): ReactNode {
|
||||||
|
const safeNodes = pathSafeNodes(levelNodes, ancestorIds);
|
||||||
|
if (safeNodes.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[childrenBaseClassName, root ? className : ""].filter(Boolean).join(" ")}>
|
||||||
|
{safeNodes.map(({ id: nodeId, node }) => {
|
||||||
|
const nextAncestorIds = new Set(ancestorIds);
|
||||||
|
nextAncestorIds.add(nodeId);
|
||||||
|
const children = getNodeChildren(node);
|
||||||
|
const hasChildren = pathSafeNodes(children, nextAncestorIds).length > 0;
|
||||||
|
const expanded = collapsible ? expandedIds.has(nodeId) : hasChildren;
|
||||||
|
const active = activeId === nodeId;
|
||||||
|
const context: ExplorerTreeNodeContext = { depth: currentDepth, active, expanded, hasChildren, disabled };
|
||||||
|
const draggable = getNodeDraggable?.(node, context) ?? false;
|
||||||
|
const nodeActions = renderNodeActions?.(node, context);
|
||||||
|
const hasNodeActions = nodeActions !== null && nodeActions !== undefined && nodeActions !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={nodeId} className={nodeContainerClassName}>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
nodeWrapBaseClassName,
|
||||||
|
active ? "is-active" : "",
|
||||||
|
hasNodeActions ? "has-actions" : "",
|
||||||
|
getNodeWrapClassName?.(node, context) ?? ""
|
||||||
|
].filter(Boolean).join(" ")}
|
||||||
|
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(currentDepth * 14, 56)}px` }}
|
||||||
|
draggable={draggable && !disabled}
|
||||||
|
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
|
||||||
|
onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined}
|
||||||
|
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
|
||||||
|
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
|
||||||
|
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
|
||||||
|
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
|
||||||
|
>
|
||||||
|
{collapsible ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={toggleBaseClassName}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (hasChildren) onToggle?.(node, context);
|
||||||
|
}}
|
||||||
|
disabled={disabled || !hasChildren}
|
||||||
|
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", {
|
||||||
|
value0: expanded ? "i18n:govoplan-core.collapse.9cf188d3" : "i18n:govoplan-core.expand.9869e506",
|
||||||
|
value1: getNodeLabel(node)
|
||||||
|
})}
|
||||||
|
aria-expanded={hasChildren ? expanded : undefined}
|
||||||
|
>
|
||||||
|
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={`${toggleBaseClassName} explorer-tree-toggle-static`} aria-hidden="true">
|
||||||
|
{renderToggleIcon?.(node, context) ?? (hasChildren ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[nodeButtonBaseClassName, getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
||||||
|
onClick={() => onOpen(node, context)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-current={active ? "true" : undefined}
|
||||||
|
>
|
||||||
|
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
||||||
|
</button>
|
||||||
|
{hasNodeActions && (
|
||||||
|
<div
|
||||||
|
className="explorer-tree-actions"
|
||||||
|
role="group"
|
||||||
|
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", {
|
||||||
|
value0: "i18n:govoplan-core.actions.c3cd636a",
|
||||||
|
value1: getNodeLabel(node)
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{nodeActions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasChildren && expanded && renderLevel(children, currentDepth + 1, nextAncestorIds)}
|
||||||
</div>
|
</div>
|
||||||
{hasChildren && expanded &&
|
);
|
||||||
<ExplorerTree
|
})}
|
||||||
nodes={children}
|
</div>
|
||||||
getNodeId={getNodeId}
|
);
|
||||||
getNodeLabel={getNodeLabel}
|
}
|
||||||
getNodeChildren={getNodeChildren}
|
|
||||||
activeId={activeId}
|
|
||||||
expandedIds={expandedIds}
|
|
||||||
onOpen={onOpen}
|
|
||||||
onToggle={onToggle}
|
|
||||||
disabled={disabled}
|
|
||||||
depth={depth + 1}
|
|
||||||
childrenBaseClassName={childrenBaseClassName}
|
|
||||||
nodeContainerClassName={nodeContainerClassName}
|
|
||||||
nodeWrapBaseClassName={nodeWrapBaseClassName}
|
|
||||||
toggleBaseClassName={toggleBaseClassName}
|
|
||||||
nodeButtonBaseClassName={nodeButtonBaseClassName}
|
|
||||||
renderToggleIcon={renderToggleIcon}
|
|
||||||
renderNodeContent={renderNodeContent}
|
|
||||||
getNodeWrapClassName={getNodeWrapClassName}
|
|
||||||
getNodeWrapStyle={getNodeWrapStyle}
|
|
||||||
getNodeButtonClassName={getNodeButtonClassName}
|
|
||||||
getNodeDraggable={getNodeDraggable}
|
|
||||||
onContextMenu={onContextMenu}
|
|
||||||
onDragStart={onDragStart}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDrop={onDrop} />
|
|
||||||
|
|
||||||
}
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
})}
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
|
return renderLevel(nodes, depth, new Set(), true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
import Button, { type ButtonProps } from "./Button";
|
||||||
|
|
||||||
|
export type IconButtonProps = Omit<ButtonProps, "aria-label" | "children" | "title"> & {
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function IconButton({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
className = "",
|
||||||
|
type = "button",
|
||||||
|
...buttonProps
|
||||||
|
}: IconButtonProps) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const translatedLabel = translateText(label);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
{...buttonProps}
|
||||||
|
type={type}
|
||||||
|
className={["icon-button", className].filter(Boolean).join(" ")}
|
||||||
|
aria-label={translatedLabel}
|
||||||
|
title={translatedLabel}
|
||||||
|
>
|
||||||
|
<span className="icon-button-icon" aria-hidden="true">{icon}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ResourceAccessExplanationResponse } from "../api/resourceAccessContracts";
|
||||||
|
|
||||||
|
export type ResourceAccessExplanationProps = {
|
||||||
|
loading?: boolean;
|
||||||
|
explanation?: ResourceAccessExplanationResponse | null;
|
||||||
|
fallbackResourceLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ResourceAccessExplanation({
|
||||||
|
loading = false,
|
||||||
|
explanation,
|
||||||
|
fallbackResourceLabel = ""
|
||||||
|
}: ResourceAccessExplanationProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <p className="muted small-note">i18n:govoplan-core.loading_access_explanation.04a7c934</p>;
|
||||||
|
}
|
||||||
|
if (!explanation) return null;
|
||||||
|
|
||||||
|
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
|
||||||
|
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label ||
|
||||||
|
fallbackResourceLabel ||
|
||||||
|
explanation.resource_id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-grid compact responsive-form-grid">
|
||||||
|
<div><span className="form-label">i18n:govoplan-core.user.9f8a2389</span><p>{userLabel}</p></div>
|
||||||
|
<div><span className="form-label">i18n:govoplan-core.resource.d1c626a9</span><p>{resourceLabel}</p></div>
|
||||||
|
<div><span className="form-label">i18n:govoplan-core.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
|
||||||
|
<div><span className="form-label">i18n:govoplan-core.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
|
||||||
|
</div>
|
||||||
|
{explanation.provenance.length === 0 ? (
|
||||||
|
<p className="muted small-note">i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e</p>
|
||||||
|
) : (
|
||||||
|
<div className="admin-assignment-grid">
|
||||||
|
{explanation.provenance.map((item, index) => (
|
||||||
|
<div key={`${item.kind}:${item.id ?? index}`}>
|
||||||
|
<strong>{resourceAccessProvenanceKindLabel(item.kind)}</strong>
|
||||||
|
<div className="muted small-note">
|
||||||
|
{item.source || "i18n:govoplan-core.no_source.6dcf9723"}
|
||||||
|
{item.id && <> · <code>{item.id}</code></>}
|
||||||
|
</div>
|
||||||
|
{item.label && <p>{item.label}</p>}
|
||||||
|
{Object.keys(item.details ?? {}).length > 0 && (
|
||||||
|
<p className="muted small-note">{formatResourceAccessProvenanceDetails(item.details ?? {})}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resourceAccessProvenanceKindLabel(kind: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case "resource":
|
||||||
|
return "i18n:govoplan-core.resource.d1c626a9";
|
||||||
|
case "owner":
|
||||||
|
return "i18n:govoplan-core.owner.89ff3122";
|
||||||
|
case "share":
|
||||||
|
return "i18n:govoplan-core.share.09ca55ca";
|
||||||
|
case "policy":
|
||||||
|
return "i18n:govoplan-core.policy.0b779a05";
|
||||||
|
case "role":
|
||||||
|
return "i18n:govoplan-core.role.b5b4a5a2";
|
||||||
|
case "right":
|
||||||
|
return "i18n:govoplan-core.permission.2f81a22d";
|
||||||
|
default:
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatResourceAccessProvenanceDetails(details: Record<string, unknown>): string {
|
||||||
|
return Object.entries(details)
|
||||||
|
.map(([key, value]) => `${key}: ${formatResourceAccessProvenanceValue(value)}`)
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResourceAccessProvenanceValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "i18n:govoplan-core.none.6eef6648";
|
||||||
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return JSON.stringify(value) ?? String(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||||
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
|
||||||
|
export type SelectionListProps = Omit<HTMLAttributes<HTMLDivElement>, "children" | "role"> & {
|
||||||
|
children: ReactNode;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectionListItemProps = Omit<
|
||||||
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
"aria-selected" | "children" | "role"
|
||||||
|
> & {
|
||||||
|
children: ReactNode;
|
||||||
|
selected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SelectionList({
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: SelectionListProps) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const rootClassName = ["selection-list", className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={rootClassName}
|
||||||
|
role="listbox"
|
||||||
|
aria-label={label ? translateText(label) : ariaLabel}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectionListItem({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
selected,
|
||||||
|
type = "button",
|
||||||
|
...props
|
||||||
|
}: SelectionListItemProps) {
|
||||||
|
const itemClassName = [
|
||||||
|
"selection-list-item",
|
||||||
|
selected ? "is-selected" : "",
|
||||||
|
disabled ? "is-disabled" : "",
|
||||||
|
className
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
type={type}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-disabled={disabled || undefined}
|
||||||
|
className={itemClassName}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +1,8 @@
|
|||||||
import type { ReactNode } from "react";
|
import IconButton, { type IconButtonProps } from "../IconButton";
|
||||||
import Button from "../Button";
|
|
||||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
|
||||||
|
|
||||||
type Props = {
|
export type AdminIconButtonProps = IconButtonProps;
|
||||||
label: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
onClick?: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminIconButton({ label, icon, onClick, disabled = false, variant = "secondary" }: Props) {
|
/** @deprecated Prefer the neutral IconButton export for new consumers. */
|
||||||
const { translateText } = usePlatformLanguage();
|
export default function AdminIconButton({ className = "", ...props }: AdminIconButtonProps) {
|
||||||
const translatedLabel = translateText(label);
|
return <IconButton {...props} className={["admin-icon-button", className].filter(Boolean).join(" ")} />;
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={variant}
|
|
||||||
className="admin-icon-button"
|
|
||||||
aria-label={translatedLabel}
|
|
||||||
title={translatedLabel}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
import type { ReactNode } from "react";
|
||||||
|
import { translateReactNode, usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: ReactNode;
|
||||||
description?: string | null;
|
description?: ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,11 +38,11 @@ export default function AdminSelectionList({
|
|||||||
}} />
|
}} />
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<strong>{translateText(option.label)}</strong>
|
<strong>{translateReactNode(option.label, translateText)}</strong>
|
||||||
{option.description && <small>{translateText(option.description)}</small>}
|
{option.description && <small>{translateReactNode(option.description, translateText)}</small>}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
|
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
|
||||||
import StatusBadge from "../StatusBadge";
|
import StatusBadge from "../StatusBadge";
|
||||||
import Button from "../Button";
|
import TableActionGroup from "./TableActionGroup";
|
||||||
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
|
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
|
||||||
|
|
||||||
export type DataGridSortDirection = "asc" | "desc";
|
export type DataGridSortDirection = "asc" | "desc";
|
||||||
@@ -793,13 +793,28 @@ export default function DataGrid<T>({
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridPaginationBar({
|
export type DataGridPaginationBarProps = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalRows: number;
|
||||||
|
pageCount?: number;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DataGridPaginationBar({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
totalRows,
|
totalRows,
|
||||||
pageCount,
|
pageCount = Math.max(1, Math.ceil(totalRows / pageSize)),
|
||||||
pageSizeOptions,
|
pageSizeOptions = [10, 25, 50, 100],
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
ariaLabel = "i18n:govoplan-core.table_pagination.3665bd76",
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onPageSizeChange
|
onPageSizeChange
|
||||||
|
|
||||||
@@ -811,13 +826,13 @@ function DataGridPaginationBar({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {page: number;pageSize: number;totalRows: number;pageCount: number;pageSizeOptions: number[];disabled?: boolean;onPageChange: (page: number) => void;onPageSizeChange?: (pageSize: number) => void;}) {
|
}: DataGridPaginationBarProps) {
|
||||||
const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
|
const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
const last = Math.min(totalRows, page * pageSize);
|
const last = Math.min(totalRows, page * pageSize);
|
||||||
const options = [...new Set([...pageSizeOptions, pageSize])].filter((value) => value > 0).sort((a, b) => a - b);
|
const options = [...new Set([...pageSizeOptions, pageSize])].filter((value) => value > 0).sort((a, b) => a - b);
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
return (
|
return (
|
||||||
<div className="data-grid-pagination" aria-label={translateText("i18n:govoplan-core.table_pagination.3665bd76")}>
|
<div className={`data-grid-pagination ${className}`.trim()} aria-label={translateText(ariaLabel)}>
|
||||||
<div className="data-grid-pagination-summary">{first}–{last} {translateText("i18n:govoplan-core.of")} {totalRows}</div>
|
<div className="data-grid-pagination-summary">{first}–{last} {translateText("i18n:govoplan-core.of")} {totalRows}</div>
|
||||||
<label className="data-grid-page-size">
|
<label className="data-grid-page-size">
|
||||||
<span>{translateText("i18n:govoplan-core.rows_per_page.af2f9c1b")}</span>
|
<span>{translateText("i18n:govoplan-core.rows_per_page.af2f9c1b")}</span>
|
||||||
@@ -852,58 +867,44 @@ export function DataGridRowActions({
|
|||||||
moveUpLabel = "i18n:govoplan-core.move_row_up.b4715a19",
|
moveUpLabel = "i18n:govoplan-core.move_row_up.b4715a19",
|
||||||
moveDownLabel = "i18n:govoplan-core.move_row_down.8e2b5957"
|
moveDownLabel = "i18n:govoplan-core.move_row_down.8e2b5957"
|
||||||
}: DataGridRowActionsProps) {
|
}: DataGridRowActionsProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
|
||||||
const translatedAddLabel = translateText(addLabel);
|
|
||||||
const translatedRemoveLabel = translateText(removeLabel);
|
|
||||||
const translatedMoveUpLabel = translateText(moveUpLabel);
|
|
||||||
const translatedMoveDownLabel = translateText(moveDownLabel);
|
|
||||||
return (
|
return (
|
||||||
<div className="data-grid-row-actions">
|
<TableActionGroup
|
||||||
<Button
|
className="data-grid-row-actions"
|
||||||
type="button"
|
actions={[
|
||||||
variant="primary"
|
{
|
||||||
className="data-grid-row-action is-add"
|
id: "add",
|
||||||
aria-label={translatedAddLabel}
|
label: addLabel,
|
||||||
title={translatedAddLabel}
|
icon: <Plus size={16} aria-hidden="true" />,
|
||||||
disabled={disabled}
|
variant: "primary",
|
||||||
onClick={onAddBelow}>
|
disabled,
|
||||||
|
onClick: onAddBelow
|
||||||
<Plus size={16} aria-hidden="true" />
|
},
|
||||||
</Button>
|
{
|
||||||
<Button
|
id: "move-up",
|
||||||
type="button"
|
label: moveUpLabel,
|
||||||
variant="secondary"
|
icon: <ArrowUp size={16} aria-hidden="true" />,
|
||||||
className="data-grid-row-action is-reorder"
|
applicable: Boolean(onMoveUp),
|
||||||
aria-label={translatedMoveUpLabel}
|
disabled,
|
||||||
title={translatedMoveUpLabel}
|
onClick: () => onMoveUp?.()
|
||||||
disabled={disabled || !onMoveUp}
|
},
|
||||||
onClick={() => onMoveUp?.()}>
|
{
|
||||||
|
id: "move-down",
|
||||||
<ArrowUp size={16} aria-hidden="true" />
|
label: moveDownLabel,
|
||||||
</Button>
|
icon: <ArrowDown size={16} aria-hidden="true" />,
|
||||||
<Button
|
applicable: Boolean(onMoveDown),
|
||||||
type="button"
|
disabled,
|
||||||
variant="secondary"
|
onClick: () => onMoveDown?.()
|
||||||
className="data-grid-row-action is-reorder"
|
},
|
||||||
aria-label={translatedMoveDownLabel}
|
{
|
||||||
title={translatedMoveDownLabel}
|
id: "remove",
|
||||||
disabled={disabled || !onMoveDown}
|
label: removeLabel,
|
||||||
onClick={() => onMoveDown?.()}>
|
icon: <Trash2 size={16} aria-hidden="true" />,
|
||||||
|
variant: "danger",
|
||||||
<ArrowDown size={16} aria-hidden="true" />
|
disabled: disabled || removeDisabled,
|
||||||
</Button>
|
onClick: onRemove
|
||||||
<Button
|
}
|
||||||
type="button"
|
]}
|
||||||
variant="danger"
|
/>);
|
||||||
className="data-grid-row-action is-remove"
|
|
||||||
aria-label={translatedRemoveLabel}
|
|
||||||
title={translatedRemoveLabel}
|
|
||||||
disabled={disabled || removeDisabled}
|
|
||||||
onClick={onRemove}>
|
|
||||||
|
|
||||||
<Trash2 size={16} aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,22 +917,18 @@ export function DataGridEmptyAction({
|
|||||||
|
|
||||||
|
|
||||||
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
|
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
|
||||||
const { translateText } = usePlatformLanguage();
|
|
||||||
const translatedLabel = translateText(label);
|
|
||||||
return (
|
return (
|
||||||
<div className="data-grid-row-actions data-grid-empty-row-actions">
|
<TableActionGroup
|
||||||
<Button
|
className="data-grid-row-actions data-grid-empty-row-actions"
|
||||||
type="button"
|
actions={[{
|
||||||
variant="primary"
|
id: "add",
|
||||||
className="data-grid-row-action is-add"
|
label,
|
||||||
aria-label={translatedLabel}
|
icon: <Plus size={16} aria-hidden="true" />,
|
||||||
title={translatedLabel}
|
variant: "primary",
|
||||||
disabled={disabled}
|
disabled,
|
||||||
onClick={onAdd}>
|
onClick: onAdd
|
||||||
|
}]}
|
||||||
<Plus size={16} aria-hidden="true" />
|
/>);
|
||||||
</Button>
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { MouseEvent, ReactNode } from "react";
|
||||||
|
import Button from "../Button";
|
||||||
|
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||||
|
|
||||||
|
export type TableActionDefinition = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
/** Set to false when the action does not apply to this row. */
|
||||||
|
applicable?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TableActionGroupProps = {
|
||||||
|
actions: readonly (TableActionDefinition | false | null | undefined)[];
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isApplicableAction(
|
||||||
|
action: TableActionDefinition | false | null | undefined
|
||||||
|
): action is TableActionDefinition {
|
||||||
|
return Boolean(action && action.applicable !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTableAction(
|
||||||
|
event: Pick<MouseEvent<HTMLButtonElement>, "stopPropagation">,
|
||||||
|
onClick: () => void
|
||||||
|
) {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The standard row-level action surface for tables and DataGrids.
|
||||||
|
*
|
||||||
|
* Callers describe only actions that make sense for the row. An action may be
|
||||||
|
* disabled when it applies but is temporarily unavailable; actions marked as
|
||||||
|
* not applicable are omitted entirely. Labels remain available to assistive
|
||||||
|
* technology and as native tooltips while the visible controls stay icon-only.
|
||||||
|
*/
|
||||||
|
export default function TableActionGroup({
|
||||||
|
actions,
|
||||||
|
label = "i18n:govoplan-core.actions.c3cd636a",
|
||||||
|
className = ""
|
||||||
|
}: TableActionGroupProps) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const applicableActions = actions.filter(isApplicableAction);
|
||||||
|
if (applicableActions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`table-action-group${className ? ` ${className}` : ""}`}
|
||||||
|
role="group"
|
||||||
|
aria-label={translateText(label)}
|
||||||
|
>
|
||||||
|
{applicableActions.map((action) => {
|
||||||
|
const translatedLabel = translateText(action.label);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={action.id}
|
||||||
|
type="button"
|
||||||
|
variant={action.variant ?? "secondary"}
|
||||||
|
className="table-action-button"
|
||||||
|
aria-label={translatedLabel}
|
||||||
|
title={translatedLabel}
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={(event) => runTableAction(event, action.onClick)}
|
||||||
|
>
|
||||||
|
<span className="table-action-icon" aria-hidden="true">
|
||||||
|
{action.icon}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export default function useOutsideDismiss<TElement extends HTMLElement = HTMLDivElement>(
|
||||||
|
active: boolean,
|
||||||
|
onDismiss: () => void,
|
||||||
|
) {
|
||||||
|
const rootRef = useRef<TElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (!rootRef.current || rootRef.current.contains(event.target as Node)) return;
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") onDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [active, onDismiss]);
|
||||||
|
|
||||||
|
return rootRef;
|
||||||
|
}
|
||||||
@@ -6,12 +6,14 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.about.6b21fb79": "About",
|
"i18n:govoplan-core.about.6b21fb79": "About",
|
||||||
"i18n:govoplan-core.accent_color.e49578ed": "Accent color",
|
"i18n:govoplan-core.accent_color.e49578ed": "Accent color",
|
||||||
"i18n:govoplan-core.access_is_restricted_sign_in_to_open_available_m.2a47a549": "Access is restricted. Sign in to open available modules and administration.",
|
"i18n:govoplan-core.access_is_restricted_sign_in_to_open_available_m.2a47a549": "Access is restricted. Sign in to open available modules and administration.",
|
||||||
|
"i18n:govoplan-core.access_explanation.75ee7f62": "Access explanation",
|
||||||
"i18n:govoplan-core.accessible_to_you.266eca4e": "Accessible to you",
|
"i18n:govoplan-core.accessible_to_you.266eca4e": "Accessible to you",
|
||||||
"i18n:govoplan-core.account_context.5bb0a918": "Account context",
|
"i18n:govoplan-core.account_context.5bb0a918": "Account context",
|
||||||
"i18n:govoplan-core.account_display_name.03ed8fc5": "Account display name",
|
"i18n:govoplan-core.account_display_name.03ed8fc5": "Account display name",
|
||||||
"i18n:govoplan-core.account_id.c5e0db28": "Account ID",
|
"i18n:govoplan-core.account_id.c5e0db28": "Account ID",
|
||||||
"i18n:govoplan-core.account_settings.82cf8a5f": "Account settings",
|
"i18n:govoplan-core.account_settings.82cf8a5f": "Account settings",
|
||||||
"i18n:govoplan-core.account.f967543b": "ACCOUNT",
|
"i18n:govoplan-core.account.f967543b": "ACCOUNT",
|
||||||
|
"i18n:govoplan-core.action.97c89a4d": "Action",
|
||||||
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_m.2e6863d1": "Action or review severity when this rule finds multiple possible matches.",
|
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_m.2e6863d1": "Action or review severity when this rule finds multiple possible matches.",
|
||||||
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_n.bc500ee1": "Action or review severity when this rule finds no matching file.",
|
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_n.bc500ee1": "Action or review severity when this rule finds no matching file.",
|
||||||
"i18n:govoplan-core.actions.c3cd636a": "Actions",
|
"i18n:govoplan-core.actions.c3cd636a": "Actions",
|
||||||
@@ -167,6 +169,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.entry_did_not_export_a_platformwebmodule.9a157741": "entry did not export a PlatformWebModule",
|
"i18n:govoplan-core.entry_did_not_export_a_platformwebmodule.9a157741": "entry did not export a PlatformWebModule",
|
||||||
"i18n:govoplan-core.entry_integrity_check_failed.1d8f6b89": "entry integrity check failed",
|
"i18n:govoplan-core.entry_integrity_check_failed.1d8f6b89": "entry integrity check failed",
|
||||||
"i18n:govoplan-core.equals.09b6a6dc": "Equals",
|
"i18n:govoplan-core.equals.09b6a6dc": "Equals",
|
||||||
|
"i18n:govoplan-core.evidence.8487d192": "Evidence",
|
||||||
"i18n:govoplan-core.excel_spreadsheet.7107fea2": "Excel spreadsheet",
|
"i18n:govoplan-core.excel_spreadsheet.7107fea2": "Excel spreadsheet",
|
||||||
"i18n:govoplan-core.expand.9869e506": "Expand",
|
"i18n:govoplan-core.expand.9869e506": "Expand",
|
||||||
"i18n:govoplan-core.explicit_api_url.b117b4b8": "Explicit API URL",
|
"i18n:govoplan-core.explicit_api_url.b117b4b8": "Explicit API URL",
|
||||||
@@ -253,6 +256,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.less_or_equal.2860e695": "Less or equal",
|
"i18n:govoplan-core.less_or_equal.2860e695": "Less or equal",
|
||||||
"i18n:govoplan-core.less_than.1d3d412a": "Less than",
|
"i18n:govoplan-core.less_than.1d3d412a": "Less than",
|
||||||
"i18n:govoplan-core.library_template.c62eb776": "Library template",
|
"i18n:govoplan-core.library_template.c62eb776": "Library template",
|
||||||
|
"i18n:govoplan-core.loading_access_explanation.04a7c934": "Loading access explanation...",
|
||||||
"i18n:govoplan-core.loading_administration_data.643bd894": "Loading administration data...",
|
"i18n:govoplan-core.loading_administration_data.643bd894": "Loading administration data...",
|
||||||
"i18n:govoplan-core.loading_campaigns.43ea3afa": "Loading campaigns...",
|
"i18n:govoplan-core.loading_campaigns.43ea3afa": "Loading campaigns...",
|
||||||
"i18n:govoplan-core.loading_data.089f19c5": "Loading data…",
|
"i18n:govoplan-core.loading_data.089f19c5": "Loading data…",
|
||||||
@@ -314,6 +318,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.next_month.8abf7cf1": "Next month",
|
"i18n:govoplan-core.next_month.8abf7cf1": "Next month",
|
||||||
"i18n:govoplan-core.next_page.4bfc194b": "Next page",
|
"i18n:govoplan-core.next_page.4bfc194b": "Next page",
|
||||||
"i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade": "Next passes will add functionality here.",
|
"i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade": "Next passes will add functionality here.",
|
||||||
|
"i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e": "No access evidence was returned.",
|
||||||
"i18n:govoplan-core.no_accessible_campaigns_found.0b74419a": "No accessible campaigns found.",
|
"i18n:govoplan-core.no_accessible_campaigns_found.0b74419a": "No accessible campaigns found.",
|
||||||
"i18n:govoplan-core.no_address_added_yet.809c4247": "No address added yet.",
|
"i18n:govoplan-core.no_address_added_yet.809c4247": "No address added yet.",
|
||||||
"i18n:govoplan-core.no_entries_configured.48e7b97c": "No entries configured.",
|
"i18n:govoplan-core.no_entries_configured.48e7b97c": "No entries configured.",
|
||||||
@@ -323,6 +328,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.no_value_available": "No {value0} available.",
|
"i18n:govoplan-core.no_value_available": "No {value0} available.",
|
||||||
"i18n:govoplan-core.no_readable_body_content.37643a01": "No readable body content.",
|
"i18n:govoplan-core.no_readable_body_content.37643a01": "No readable body content.",
|
||||||
"i18n:govoplan-core.no_rows_found.0c1a8d34": "No rows found.",
|
"i18n:govoplan-core.no_rows_found.0c1a8d34": "No rows found.",
|
||||||
|
"i18n:govoplan-core.no_source.6dcf9723": "No source",
|
||||||
"i18n:govoplan-core.no_subject.49b20da0": "(no subject)",
|
"i18n:govoplan-core.no_subject.49b20da0": "(no subject)",
|
||||||
"i18n:govoplan-core.no_values_are_configured_for_this_column.16e935e8": "No values are configured for this column.",
|
"i18n:govoplan-core.no_values_are_configured_for_this_column.16e935e8": "No values are configured for this column.",
|
||||||
"i18n:govoplan-core.none.6eef6648": "None",
|
"i18n:govoplan-core.none.6eef6648": "None",
|
||||||
@@ -343,6 +349,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97": "Optional HTML version of the email body for richer formatting.",
|
"i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97": "Optional HTML version of the email body for richer formatting.",
|
||||||
"i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14": "Optional local alias for the active tenant. It does not replace the global account name in the title bar.",
|
"i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14": "Optional local alias for the active tenant. It does not replace the global account name in the title bar.",
|
||||||
"i18n:govoplan-core.or_click_to_select_files.91b05dc1": "or click to select files",
|
"i18n:govoplan-core.or_click_to_select_files.91b05dc1": "or click to select files",
|
||||||
|
"i18n:govoplan-core.owner.89ff3122": "Owner",
|
||||||
"i18n:govoplan-core.owner_policy.1e8df143": "Owner policy",
|
"i18n:govoplan-core.owner_policy.1e8df143": "Owner policy",
|
||||||
"i18n:govoplan-core.p_no_html_body_content_p.c305bfc6": "<p>No HTML body content.</p>",
|
"i18n:govoplan-core.p_no_html_body_content_p.c305bfc6": "<p>No HTML body content.</p>",
|
||||||
"i18n:govoplan-core.page.fb06270f": "Page",
|
"i18n:govoplan-core.page.fb06270f": "Page",
|
||||||
@@ -351,6 +358,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.password.8be3c943": "Password",
|
"i18n:govoplan-core.password.8be3c943": "Password",
|
||||||
"i18n:govoplan-core.path_or_identifier_for_an_external_recipient_sou.f7da6e8f": "Path or identifier for an external recipient source such as a CSV file.",
|
"i18n:govoplan-core.path_or_identifier_for_an_external_recipient_sou.f7da6e8f": "Path or identifier for an external recipient source such as a CSV file.",
|
||||||
"i18n:govoplan-core.pdf.d613d88c": "PDF",
|
"i18n:govoplan-core.pdf.d613d88c": "PDF",
|
||||||
|
"i18n:govoplan-core.permission.2f81a22d": "Permission",
|
||||||
"i18n:govoplan-core.permit_per_recipient_sender_overrides_when_build.62e46d13": "Permit per-recipient sender overrides when building messages.",
|
"i18n:govoplan-core.permit_per_recipient_sender_overrides_when_build.62e46d13": "Permit per-recipient sender overrides when building messages.",
|
||||||
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_bcc_re.46d737dc": "Permit recipient rows to define their own BCC recipients.",
|
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_bcc_re.46d737dc": "Permit recipient rows to define their own BCC recipients.",
|
||||||
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_cc_rec.0247b3cd": "Permit recipient rows to define their own CC recipients.",
|
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_cc_rec.0247b3cd": "Permit recipient rows to define their own CC recipients.",
|
||||||
@@ -366,6 +374,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.please_wait_while_the_local_session_is_verified.1dbd8fd3": "Please wait while the local session is verified.",
|
"i18n:govoplan-core.please_wait_while_the_local_session_is_verified.1dbd8fd3": "Please wait while the local session is verified.",
|
||||||
"i18n:govoplan-core.png_image.6715d4b8": "PNG image",
|
"i18n:govoplan-core.png_image.6715d4b8": "PNG image",
|
||||||
"i18n:govoplan-core.preferences_saved.c8cd3501": "Preferences saved.",
|
"i18n:govoplan-core.preferences_saved.c8cd3501": "Preferences saved.",
|
||||||
|
"i18n:govoplan-core.policy.0b779a05": "Policy",
|
||||||
"i18n:govoplan-core.policies.8d611849": "Policies",
|
"i18n:govoplan-core.policies.8d611849": "Policies",
|
||||||
"i18n:govoplan-core.port.fe035157": "Port",
|
"i18n:govoplan-core.port.fe035157": "Port",
|
||||||
"i18n:govoplan-core.powerpoint_presentation.b32b7115": "PowerPoint presentation",
|
"i18n:govoplan-core.powerpoint_presentation.b32b7115": "PowerPoint presentation",
|
||||||
@@ -403,10 +412,12 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.request_failed.9fcda32c": "Request failed",
|
"i18n:govoplan-core.request_failed.9fcda32c": "Request failed",
|
||||||
"i18n:govoplan-core.required.eed6bfb4": "Required",
|
"i18n:govoplan-core.required.eed6bfb4": "Required",
|
||||||
"i18n:govoplan-core.resize.f52dc753": "Resize",
|
"i18n:govoplan-core.resize.f52dc753": "Resize",
|
||||||
|
"i18n:govoplan-core.resource.d1c626a9": "Resource",
|
||||||
"i18n:govoplan-core.retention_policy_saved.eb577758": "Retention policy saved.",
|
"i18n:govoplan-core.retention_policy_saved.eb577758": "Retention policy saved.",
|
||||||
"i18n:govoplan-core.retention_policy.962fb418": "Retention policy",
|
"i18n:govoplan-core.retention_policy.962fb418": "Retention policy",
|
||||||
"i18n:govoplan-core.reusable_template_record_this_campaign_should_re.5896529b": "Reusable template record this campaign should refer to once the template backend is available.",
|
"i18n:govoplan-core.reusable_template_record_this_campaign_should_re.5896529b": "Reusable template record this campaign should refer to once the template backend is available.",
|
||||||
"i18n:govoplan-core.review_send.1627617d": "Review & Send",
|
"i18n:govoplan-core.review_send.1627617d": "Review & Send",
|
||||||
|
"i18n:govoplan-core.role.b5b4a5a2": "Role",
|
||||||
"i18n:govoplan-core.rows_per_page.af2f9c1b": "Rows per page",
|
"i18n:govoplan-core.rows_per_page.af2f9c1b": "Rows per page",
|
||||||
"i18n:govoplan-core.sa.50cf95ce": "Sa",
|
"i18n:govoplan-core.sa.50cf95ce": "Sa",
|
||||||
"i18n:govoplan-core.same_origin_proxied.c39e6e2b": "Same-origin / proxied",
|
"i18n:govoplan-core.same_origin_proxied.c39e6e2b": "Same-origin / proxied",
|
||||||
@@ -428,6 +439,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.server.cb0cb170": "Server",
|
"i18n:govoplan-core.server.cb0cb170": "Server",
|
||||||
"i18n:govoplan-core.session_expired.b828190e": "Session expired",
|
"i18n:govoplan-core.session_expired.b828190e": "Session expired",
|
||||||
"i18n:govoplan-core.session_state.b7a3d0f4": "Session state",
|
"i18n:govoplan-core.session_state.b7a3d0f4": "Session state",
|
||||||
|
"i18n:govoplan-core.share.09ca55ca": "Share",
|
||||||
"i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.",
|
"i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.",
|
||||||
"i18n:govoplan-core.settings.c7f73bb5": "Settings",
|
"i18n:govoplan-core.settings.c7f73bb5": "Settings",
|
||||||
"i18n:govoplan-core.show_content.0528d8d2": "Show content",
|
"i18n:govoplan-core.show_content.0528d8d2": "Show content",
|
||||||
@@ -563,12 +575,14 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.about.6b21fb79": "About",
|
"i18n:govoplan-core.about.6b21fb79": "About",
|
||||||
"i18n:govoplan-core.accent_color.e49578ed": "Accent color",
|
"i18n:govoplan-core.accent_color.e49578ed": "Accent color",
|
||||||
"i18n:govoplan-core.access_is_restricted_sign_in_to_open_available_m.2a47a549": "Access is restricted. Sign in to open available modules and administration.",
|
"i18n:govoplan-core.access_is_restricted_sign_in_to_open_available_m.2a47a549": "Access is restricted. Sign in to open available modules and administration.",
|
||||||
|
"i18n:govoplan-core.access_explanation.75ee7f62": "Zugriffserklärung",
|
||||||
"i18n:govoplan-core.accessible_to_you.266eca4e": "Accessible to you",
|
"i18n:govoplan-core.accessible_to_you.266eca4e": "Accessible to you",
|
||||||
"i18n:govoplan-core.account_context.5bb0a918": "Account context",
|
"i18n:govoplan-core.account_context.5bb0a918": "Account context",
|
||||||
"i18n:govoplan-core.account_display_name.03ed8fc5": "Account display name",
|
"i18n:govoplan-core.account_display_name.03ed8fc5": "Account display name",
|
||||||
"i18n:govoplan-core.account_id.c5e0db28": "Account ID",
|
"i18n:govoplan-core.account_id.c5e0db28": "Account ID",
|
||||||
"i18n:govoplan-core.account_settings.82cf8a5f": "Account settings",
|
"i18n:govoplan-core.account_settings.82cf8a5f": "Account settings",
|
||||||
"i18n:govoplan-core.account.f967543b": "ACCOUNT",
|
"i18n:govoplan-core.account.f967543b": "ACCOUNT",
|
||||||
|
"i18n:govoplan-core.action.97c89a4d": "Aktion",
|
||||||
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_m.2e6863d1": "Action or review severity when this rule finds multiple possible matches.",
|
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_m.2e6863d1": "Action or review severity when this rule finds multiple possible matches.",
|
||||||
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_n.bc500ee1": "Action or review severity when this rule finds no matching file.",
|
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_n.bc500ee1": "Action or review severity when this rule finds no matching file.",
|
||||||
"i18n:govoplan-core.actions.c3cd636a": "Aktionen",
|
"i18n:govoplan-core.actions.c3cd636a": "Aktionen",
|
||||||
@@ -724,6 +738,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.entry_did_not_export_a_platformwebmodule.9a157741": "entry did not export a PlatformWebModule",
|
"i18n:govoplan-core.entry_did_not_export_a_platformwebmodule.9a157741": "entry did not export a PlatformWebModule",
|
||||||
"i18n:govoplan-core.entry_integrity_check_failed.1d8f6b89": "entry integrity check failed",
|
"i18n:govoplan-core.entry_integrity_check_failed.1d8f6b89": "entry integrity check failed",
|
||||||
"i18n:govoplan-core.equals.09b6a6dc": "Equals",
|
"i18n:govoplan-core.equals.09b6a6dc": "Equals",
|
||||||
|
"i18n:govoplan-core.evidence.8487d192": "Nachweise",
|
||||||
"i18n:govoplan-core.excel_spreadsheet.7107fea2": "Excel spreadsheet",
|
"i18n:govoplan-core.excel_spreadsheet.7107fea2": "Excel spreadsheet",
|
||||||
"i18n:govoplan-core.expand.9869e506": "Expand",
|
"i18n:govoplan-core.expand.9869e506": "Expand",
|
||||||
"i18n:govoplan-core.explicit_api_url.b117b4b8": "Explicit API URL",
|
"i18n:govoplan-core.explicit_api_url.b117b4b8": "Explicit API URL",
|
||||||
@@ -810,6 +825,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.less_or_equal.2860e695": "Less or equal",
|
"i18n:govoplan-core.less_or_equal.2860e695": "Less or equal",
|
||||||
"i18n:govoplan-core.less_than.1d3d412a": "Less than",
|
"i18n:govoplan-core.less_than.1d3d412a": "Less than",
|
||||||
"i18n:govoplan-core.library_template.c62eb776": "Library template",
|
"i18n:govoplan-core.library_template.c62eb776": "Library template",
|
||||||
|
"i18n:govoplan-core.loading_access_explanation.04a7c934": "Zugriffserklärung wird geladen...",
|
||||||
"i18n:govoplan-core.loading_administration_data.643bd894": "Loading administration data...",
|
"i18n:govoplan-core.loading_administration_data.643bd894": "Loading administration data...",
|
||||||
"i18n:govoplan-core.loading_campaigns.43ea3afa": "Loading campaigns...",
|
"i18n:govoplan-core.loading_campaigns.43ea3afa": "Loading campaigns...",
|
||||||
"i18n:govoplan-core.loading_data.089f19c5": "Loading data…",
|
"i18n:govoplan-core.loading_data.089f19c5": "Loading data…",
|
||||||
@@ -871,6 +887,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.next_month.8abf7cf1": "Next month",
|
"i18n:govoplan-core.next_month.8abf7cf1": "Next month",
|
||||||
"i18n:govoplan-core.next_page.4bfc194b": "Next page",
|
"i18n:govoplan-core.next_page.4bfc194b": "Next page",
|
||||||
"i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade": "Die nächsten Durchläufe ergänzen hier die Funktionalität.",
|
"i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade": "Die nächsten Durchläufe ergänzen hier die Funktionalität.",
|
||||||
|
"i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.",
|
||||||
"i18n:govoplan-core.no_accessible_campaigns_found.0b74419a": "No accessible campaigns found.",
|
"i18n:govoplan-core.no_accessible_campaigns_found.0b74419a": "No accessible campaigns found.",
|
||||||
"i18n:govoplan-core.no_address_added_yet.809c4247": "No address added yet.",
|
"i18n:govoplan-core.no_address_added_yet.809c4247": "No address added yet.",
|
||||||
"i18n:govoplan-core.no_entries_configured.48e7b97c": "No entries configured.",
|
"i18n:govoplan-core.no_entries_configured.48e7b97c": "No entries configured.",
|
||||||
@@ -880,6 +897,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.no_value_available": "Keine {value0} verfügbar.",
|
"i18n:govoplan-core.no_value_available": "Keine {value0} verfügbar.",
|
||||||
"i18n:govoplan-core.no_readable_body_content.37643a01": "No readable body content.",
|
"i18n:govoplan-core.no_readable_body_content.37643a01": "No readable body content.",
|
||||||
"i18n:govoplan-core.no_rows_found.0c1a8d34": "No rows found.",
|
"i18n:govoplan-core.no_rows_found.0c1a8d34": "No rows found.",
|
||||||
|
"i18n:govoplan-core.no_source.6dcf9723": "Keine Quelle",
|
||||||
"i18n:govoplan-core.no_subject.49b20da0": "(no subject)",
|
"i18n:govoplan-core.no_subject.49b20da0": "(no subject)",
|
||||||
"i18n:govoplan-core.no_values_are_configured_for_this_column.16e935e8": "No values are configured for this column.",
|
"i18n:govoplan-core.no_values_are_configured_for_this_column.16e935e8": "No values are configured for this column.",
|
||||||
"i18n:govoplan-core.none.6eef6648": "Keine",
|
"i18n:govoplan-core.none.6eef6648": "Keine",
|
||||||
@@ -900,6 +918,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97": "Optional HTML version of the email body for richer formatting.",
|
"i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97": "Optional HTML version of the email body for richer formatting.",
|
||||||
"i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14": "Optional local alias for the active tenant. It does not replace the global account name in the title bar.",
|
"i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14": "Optional local alias for the active tenant. It does not replace the global account name in the title bar.",
|
||||||
"i18n:govoplan-core.or_click_to_select_files.91b05dc1": "or click to select files",
|
"i18n:govoplan-core.or_click_to_select_files.91b05dc1": "or click to select files",
|
||||||
|
"i18n:govoplan-core.owner.89ff3122": "Eigentümer",
|
||||||
"i18n:govoplan-core.owner_policy.1e8df143": "Owner policy",
|
"i18n:govoplan-core.owner_policy.1e8df143": "Owner policy",
|
||||||
"i18n:govoplan-core.p_no_html_body_content_p.c305bfc6": "<p>No HTML body content.</p>",
|
"i18n:govoplan-core.p_no_html_body_content_p.c305bfc6": "<p>No HTML body content.</p>",
|
||||||
"i18n:govoplan-core.page.fb06270f": "Page",
|
"i18n:govoplan-core.page.fb06270f": "Page",
|
||||||
@@ -908,6 +927,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.password.8be3c943": "Passwort",
|
"i18n:govoplan-core.password.8be3c943": "Passwort",
|
||||||
"i18n:govoplan-core.path_or_identifier_for_an_external_recipient_sou.f7da6e8f": "Path or identifier for an external recipient source such as a CSV file.",
|
"i18n:govoplan-core.path_or_identifier_for_an_external_recipient_sou.f7da6e8f": "Path or identifier for an external recipient source such as a CSV file.",
|
||||||
"i18n:govoplan-core.pdf.d613d88c": "PDF",
|
"i18n:govoplan-core.pdf.d613d88c": "PDF",
|
||||||
|
"i18n:govoplan-core.permission.2f81a22d": "Berechtigung",
|
||||||
"i18n:govoplan-core.permit_per_recipient_sender_overrides_when_build.62e46d13": "Permit per-recipient sender overrides when building messages.",
|
"i18n:govoplan-core.permit_per_recipient_sender_overrides_when_build.62e46d13": "Permit per-recipient sender overrides when building messages.",
|
||||||
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_bcc_re.46d737dc": "Permit recipient rows to define their own BCC recipients.",
|
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_bcc_re.46d737dc": "Permit recipient rows to define their own BCC recipients.",
|
||||||
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_cc_rec.0247b3cd": "Permit recipient rows to define their own CC recipients.",
|
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_cc_rec.0247b3cd": "Permit recipient rows to define their own CC recipients.",
|
||||||
@@ -923,6 +943,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.please_wait_while_the_local_session_is_verified.1dbd8fd3": "Bitte warten Sie, während die lokale Sitzung geprüft wird.",
|
"i18n:govoplan-core.please_wait_while_the_local_session_is_verified.1dbd8fd3": "Bitte warten Sie, während die lokale Sitzung geprüft wird.",
|
||||||
"i18n:govoplan-core.png_image.6715d4b8": "PNG image",
|
"i18n:govoplan-core.png_image.6715d4b8": "PNG image",
|
||||||
"i18n:govoplan-core.preferences_saved.c8cd3501": "Einstellungen gespeichert.",
|
"i18n:govoplan-core.preferences_saved.c8cd3501": "Einstellungen gespeichert.",
|
||||||
|
"i18n:govoplan-core.policy.0b779a05": "Richtlinie",
|
||||||
"i18n:govoplan-core.policies.8d611849": "Richtlinien",
|
"i18n:govoplan-core.policies.8d611849": "Richtlinien",
|
||||||
"i18n:govoplan-core.port.fe035157": "Port",
|
"i18n:govoplan-core.port.fe035157": "Port",
|
||||||
"i18n:govoplan-core.powerpoint_presentation.b32b7115": "PowerPoint presentation",
|
"i18n:govoplan-core.powerpoint_presentation.b32b7115": "PowerPoint presentation",
|
||||||
@@ -960,10 +981,12 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.request_failed.9fcda32c": "Request failed",
|
"i18n:govoplan-core.request_failed.9fcda32c": "Request failed",
|
||||||
"i18n:govoplan-core.required.eed6bfb4": "Required",
|
"i18n:govoplan-core.required.eed6bfb4": "Required",
|
||||||
"i18n:govoplan-core.resize.f52dc753": "Resize",
|
"i18n:govoplan-core.resize.f52dc753": "Resize",
|
||||||
|
"i18n:govoplan-core.resource.d1c626a9": "Ressource",
|
||||||
"i18n:govoplan-core.retention_policy_saved.eb577758": "Retention policy saved.",
|
"i18n:govoplan-core.retention_policy_saved.eb577758": "Retention policy saved.",
|
||||||
"i18n:govoplan-core.retention_policy.962fb418": "Retention policy",
|
"i18n:govoplan-core.retention_policy.962fb418": "Retention policy",
|
||||||
"i18n:govoplan-core.reusable_template_record_this_campaign_should_re.5896529b": "Reusable template record this campaign should refer to once the template backend is available.",
|
"i18n:govoplan-core.reusable_template_record_this_campaign_should_re.5896529b": "Reusable template record this campaign should refer to once the template backend is available.",
|
||||||
"i18n:govoplan-core.review_send.1627617d": "Prüfen & Senden",
|
"i18n:govoplan-core.review_send.1627617d": "Prüfen & Senden",
|
||||||
|
"i18n:govoplan-core.role.b5b4a5a2": "Rolle",
|
||||||
"i18n:govoplan-core.rows_per_page.af2f9c1b": "Rows per page",
|
"i18n:govoplan-core.rows_per_page.af2f9c1b": "Rows per page",
|
||||||
"i18n:govoplan-core.sa.50cf95ce": "Sa",
|
"i18n:govoplan-core.sa.50cf95ce": "Sa",
|
||||||
"i18n:govoplan-core.same_origin_proxied.c39e6e2b": "Same-origin / proxied",
|
"i18n:govoplan-core.same_origin_proxied.c39e6e2b": "Same-origin / proxied",
|
||||||
@@ -985,6 +1008,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.server.cb0cb170": "Server",
|
"i18n:govoplan-core.server.cb0cb170": "Server",
|
||||||
"i18n:govoplan-core.session_expired.b828190e": "Sitzung abgelaufen",
|
"i18n:govoplan-core.session_expired.b828190e": "Sitzung abgelaufen",
|
||||||
"i18n:govoplan-core.session_state.b7a3d0f4": "Session state",
|
"i18n:govoplan-core.session_state.b7a3d0f4": "Session state",
|
||||||
|
"i18n:govoplan-core.share.09ca55ca": "Freigabe",
|
||||||
"i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.",
|
"i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.",
|
||||||
"i18n:govoplan-core.settings.c7f73bb5": "Einstellungen",
|
"i18n:govoplan-core.settings.c7f73bb5": "Einstellungen",
|
||||||
"i18n:govoplan-core.show_content.0528d8d2": "Show content",
|
"i18n:govoplan-core.show_content.0528d8d2": "Show content",
|
||||||
|
|||||||
+13
-2
@@ -24,6 +24,7 @@ export type {
|
|||||||
MockMailboxMessageResponse
|
MockMailboxMessageResponse
|
||||||
} from "./api/mailContracts";
|
} from "./api/mailContracts";
|
||||||
export * from "./api/privacyRetention";
|
export * from "./api/privacyRetention";
|
||||||
|
export * from "./api/resourceAccess";
|
||||||
export * from "./platform/modules";
|
export * from "./platform/modules";
|
||||||
export * from "./platform/ModuleContext";
|
export * from "./platform/ModuleContext";
|
||||||
export * from "./platform/moduleEvents";
|
export * from "./platform/moduleEvents";
|
||||||
@@ -43,10 +44,12 @@ export { default as ActionBlockerHint } from "./components/ActionBlockerHint";
|
|||||||
export type { ActionBlockerReason } from "./components/ActionBlockerHint";
|
export type { ActionBlockerReason } from "./components/ActionBlockerHint";
|
||||||
export { default as AdvancedOptionsPanel } from "./components/AdvancedOptionsPanel";
|
export { default as AdvancedOptionsPanel } from "./components/AdvancedOptionsPanel";
|
||||||
export { default as AdminIconButton } from "./components/admin/AdminIconButton";
|
export { default as AdminIconButton } from "./components/admin/AdminIconButton";
|
||||||
|
export type { AdminIconButtonProps } from "./components/admin/AdminIconButton";
|
||||||
export { default as AdminPageLayout } from "./components/admin/AdminPageLayout";
|
export { default as AdminPageLayout } from "./components/admin/AdminPageLayout";
|
||||||
export { default as AdminSelectionList } from "./components/admin/AdminSelectionList";
|
export { default as AdminSelectionList } from "./components/admin/AdminSelectionList";
|
||||||
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
|
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
|
||||||
export { default as Button } from "./components/Button";
|
export { default as Button } from "./components/Button";
|
||||||
|
export type { ButtonProps } from "./components/Button";
|
||||||
export { default as Card } from "./components/Card";
|
export { default as Card } from "./components/Card";
|
||||||
export { default as ConfirmDialog } from "./components/ConfirmDialog";
|
export { default as ConfirmDialog } from "./components/ConfirmDialog";
|
||||||
export { default as ConnectionTree } from "./components/ConnectionTree";
|
export { default as ConnectionTree } from "./components/ConnectionTree";
|
||||||
@@ -70,6 +73,8 @@ export { default as GuidedReviewList } from "./components/GuidedReviewList";
|
|||||||
export type { GuidedReviewItem } from "./components/GuidedReviewList";
|
export type { GuidedReviewItem } from "./components/GuidedReviewList";
|
||||||
export { default as HoverTooltip } from "./components/HoverTooltip";
|
export { default as HoverTooltip } from "./components/HoverTooltip";
|
||||||
export type { HoverTooltipProps, HoverTooltipTone } from "./components/HoverTooltip";
|
export type { HoverTooltipProps, HoverTooltipTone } from "./components/HoverTooltip";
|
||||||
|
export { default as IconButton } from "./components/IconButton";
|
||||||
|
export type { IconButtonProps } from "./components/IconButton";
|
||||||
export { default as LoadingFrame } from "./components/LoadingFrame";
|
export { default as LoadingFrame } from "./components/LoadingFrame";
|
||||||
export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
||||||
export { default as ExplorerTree } from "./components/ExplorerTree";
|
export { default as ExplorerTree } from "./components/ExplorerTree";
|
||||||
@@ -79,6 +84,8 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
|
|||||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||||
export { default as PageTitle } from "./components/PageTitle";
|
export { default as PageTitle } from "./components/PageTitle";
|
||||||
export { default as PasswordField } from "./components/PasswordField";
|
export { default as PasswordField } from "./components/PasswordField";
|
||||||
|
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
|
||||||
|
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
|
||||||
export type { PasswordFieldProps } from "./components/PasswordField";
|
export type { PasswordFieldProps } from "./components/PasswordField";
|
||||||
export { PolicyRow, PolicySection, PolicyTable } from "./components/PolicyTable";
|
export { PolicyRow, PolicySection, PolicyTable } from "./components/PolicyTable";
|
||||||
export { default as PolicyPathHelp, normalizePolicySourcePathItems } from "./components/PolicyPathHelp";
|
export { default as PolicyPathHelp, normalizePolicySourcePathItems } from "./components/PolicyPathHelp";
|
||||||
@@ -89,6 +96,8 @@ export { default as PolicyLockedHint } from "./components/PolicyLockedHint";
|
|||||||
export type { PolicyLockedHintProps } from "./components/PolicyLockedHint";
|
export type { PolicyLockedHintProps } from "./components/PolicyLockedHint";
|
||||||
export { default as SegmentedControl } from "./components/SegmentedControl";
|
export { default as SegmentedControl } from "./components/SegmentedControl";
|
||||||
export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl";
|
export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl";
|
||||||
|
export { default as SelectionList, SelectionListItem } from "./components/SelectionList";
|
||||||
|
export type { SelectionListItemProps, SelectionListProps } from "./components/SelectionList";
|
||||||
export { default as StatusBadge } from "./components/StatusBadge";
|
export { default as StatusBadge } from "./components/StatusBadge";
|
||||||
export { default as Stepper } from "./components/Stepper";
|
export { default as Stepper } from "./components/Stepper";
|
||||||
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
||||||
@@ -100,8 +109,10 @@ export { default as MailServerSettingsPanel, MailServerActionResult, MailServerF
|
|||||||
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
|
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
|
||||||
export { default as FieldLabel } from "./components/help/FieldLabel";
|
export { default as FieldLabel } from "./components/help/FieldLabel";
|
||||||
export { default as InlineHelp } from "./components/help/InlineHelp";
|
export { default as InlineHelp } from "./components/help/InlineHelp";
|
||||||
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
|
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid";
|
||||||
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid";
|
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid";
|
||||||
|
export { default as TableActionGroup } from "./components/table/TableActionGroup";
|
||||||
|
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";
|
||||||
|
|
||||||
export { default as LoginModal } from "./features/auth/LoginModal";
|
export { default as LoginModal } from "./features/auth/LoginModal";
|
||||||
export { default as PublicLandingPage } from "./features/auth/PublicLandingPage";
|
export { default as PublicLandingPage } from "./features/auth/PublicLandingPage";
|
||||||
|
|||||||
@@ -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: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
|
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
|
||||||
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text-strong); }
|
.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-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-blocked, .status-error, .status-danger, .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-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); }
|
.status-inactive, .status-locked { background: var(--status-neutral-bg); color: var(--text-soft); }
|
||||||
|
|||||||
+513
-16
@@ -117,10 +117,6 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
.recipient-editor-table th:nth-child(2),
|
|
||||||
.recipient-editor-table td:nth-child(2) { min-width: 430px; }
|
|
||||||
.recipient-editor-table th:last-child,
|
|
||||||
.recipient-editor-table td:last-child { width: 92px; text-align: right; }
|
|
||||||
.recipient-field-input,
|
.recipient-field-input,
|
||||||
.recipient-attachments-input {
|
.recipient-attachments-input {
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
@@ -957,16 +953,7 @@
|
|||||||
align-self: start;
|
align-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-icon-actions {
|
.icon-button.btn {
|
||||||
display: grid;
|
|
||||||
grid-auto-flow: column;
|
|
||||||
grid-auto-columns: 36px;
|
|
||||||
justify-content: end;
|
|
||||||
gap: 5px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-icon-button.btn {
|
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
@@ -975,7 +962,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-icon-button.btn svg {
|
.icon-button.btn svg {
|
||||||
width: 17px;
|
width: 17px;
|
||||||
height: 17px;
|
height: 17px;
|
||||||
}
|
}
|
||||||
@@ -985,11 +972,19 @@
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-form-grid > .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-form-grid.two-columns,
|
.admin-form-grid.two-columns,
|
||||||
.admin-assignment-grid {
|
.admin-assignment-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-form-grid.three-columns {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.admin-assignment-grid {
|
.admin-assignment-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -1032,8 +1027,9 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 850px) {
|
@media (max-width: 900px) {
|
||||||
.admin-form-grid.two-columns,
|
.admin-form-grid.two-columns,
|
||||||
|
.admin-form-grid.three-columns,
|
||||||
.admin-assignment-grid,
|
.admin-assignment-grid,
|
||||||
.admin-permission-groups {
|
.admin-permission-groups {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1803,6 +1799,27 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.split-field-action {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-field-action > input,
|
||||||
|
.split-field-action > select,
|
||||||
|
.split-field-action > textarea {
|
||||||
|
border-top-right-radius: 0 !important;
|
||||||
|
border-bottom-right-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-field-action > button,
|
||||||
|
.split-field-action > .button,
|
||||||
|
.split-field-action > .btn {
|
||||||
|
align-self: stretch;
|
||||||
|
margin-left: -1px;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.field-input-missing {
|
.field-input-missing {
|
||||||
border-color: var(--danger-border-deep) !important;
|
border-color: var(--danger-border-deep) !important;
|
||||||
box-shadow: var(--danger-focus-ring) !important;
|
box-shadow: var(--danger-focus-ring) !important;
|
||||||
@@ -2017,6 +2034,214 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Shared explorer/list work surfaces. Modules own their column definitions and domain interactions. */
|
||||||
|
.file-manager-page.file-manager-fullscreen {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
height: calc(100vh - 115px);
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-manager-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: var(--panel-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-manager-shell {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-sticky {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
z-index: 2;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 10px 14px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-breadcrumb,
|
||||||
|
.file-breadcrumb-segment {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-breadcrumb {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-breadcrumb:hover:not(:disabled),
|
||||||
|
.file-breadcrumb:focus-visible {
|
||||||
|
background: var(--panel);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-drop-target {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
transition: background-color .16s ease, box-shadow .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-table-head,
|
||||||
|
.file-list-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-table-head {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-row {
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-row:focus-visible {
|
||||||
|
outline: 2px solid var(--line-dark);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-row:hover,
|
||||||
|
.file-list-row.is-selected {
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-name-cell {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: default;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-name > span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-name strong,
|
||||||
|
.file-list-name small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-name small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row-icon {
|
||||||
|
color: var(--muted);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row-tail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-empty {
|
||||||
|
padding: 36px 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-manager-shell.is-loading .file-tree-panel,
|
||||||
|
.file-manager-shell.is-loading .file-list-panel {
|
||||||
|
filter: blur(1.5px);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-manager-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 35;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--panel-glass);
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Shared explorer tree and message display surfaces. */
|
/* Shared explorer tree and message display surfaces. */
|
||||||
.explorer-tree-children {
|
.explorer-tree-children {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2031,6 +2256,10 @@
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.explorer-tree-node-wrap.has-actions {
|
||||||
|
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.explorer-tree-node,
|
.explorer-tree-node,
|
||||||
.explorer-tree-toggle {
|
.explorer-tree-toggle {
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -2053,6 +2282,11 @@
|
|||||||
opacity: .55;
|
opacity: .55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.explorer-tree-toggle.explorer-tree-toggle-static {
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.explorer-tree-node {
|
.explorer-tree-node {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2072,6 +2306,44 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.explorer-tree-node-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-tree-node-content strong,
|
||||||
|
.explorer-tree-node-content small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-tree-node-content small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-tree-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-tree-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-tree-scroll-region {
|
||||||
|
max-height: 640px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.explorer-tree-node-wrap:hover,
|
.explorer-tree-node-wrap:hover,
|
||||||
.explorer-tree-node-wrap:focus-within,
|
.explorer-tree-node-wrap:focus-within,
|
||||||
.explorer-tree-node-wrap.is-active {
|
.explorer-tree-node-wrap.is-active {
|
||||||
@@ -2300,6 +2572,55 @@
|
|||||||
opacity: .55;
|
opacity: .55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selection-list {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.selection-list-item) {
|
||||||
|
appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
transition: background .16s ease, border-color .16s ease, box-shadow .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.selection-list-item):hover:not(:disabled) {
|
||||||
|
background: var(--hover-tint-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.selection-list-item).is-selected {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.selection-list-item):focus-visible {
|
||||||
|
outline: var(--focus-outline);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.selection-list-item)[draggable="true"] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.selection-list-item)[draggable="true"]:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.selection-list-item):disabled,
|
||||||
|
:where(.selection-list-item).is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: .55;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-preview-list {
|
.theme-preview-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
@@ -2789,3 +3110,179 @@
|
|||||||
@keyframes file-drop-progress-spin {
|
@keyframes file-drop-progress-spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shared settings, placeholder, and administration layout contracts. */
|
||||||
|
.placeholder-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.placeholder-stack span {
|
||||||
|
display: block;
|
||||||
|
border: 1px dashed var(--line-dark);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.compact-detail-list {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.settings-dashboard-grid {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.settings-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.settings-target-row {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
.admin-secret {
|
||||||
|
display: block;
|
||||||
|
padding: 14px;
|
||||||
|
margin: 12px 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
.admin-json-preview {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.admin-audit-grid .data-grid-scroll-region {
|
||||||
|
max-height: min(68vh, 720px);
|
||||||
|
}
|
||||||
|
.admin-audit-grid .data-grid-header-cell {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.admin-details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px 18px;
|
||||||
|
}
|
||||||
|
.admin-details-grid > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
.admin-details-grid dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.admin-details-grid dd {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.admin-governance-mode {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.admin-tenant-assignment-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 140px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.admin-settings-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.admin-settings-form .card {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.admin-managed-notice {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--info-muted-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--info-bg);
|
||||||
|
color: var(--info-text-deep);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.admin-permission-groups {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.admin-permission-group {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.admin-permission-group legend {
|
||||||
|
padding: 0 6px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.admin-protection-note {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--warning-border-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
color: var(--warning-text-strong);
|
||||||
|
}
|
||||||
|
.admin-scope-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.admin-scope-list code {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.admin-overview-section-label {
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.admin-permission-details {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.admin-permission-details section {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.admin-permission-details ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
.module-license-status {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.admin-details-grid,
|
||||||
|
.admin-tenant-assignment-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,38 @@
|
|||||||
.form-grid { display: grid; gap: 18px; }
|
.form-grid { display: grid; gap: 18px; }
|
||||||
.form-grid.compact { grid-template-columns: 1fr 1fr; }
|
.form-grid.compact,
|
||||||
|
.form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.form-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.form-grid.four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.form-grid > .wide { grid-column: 1 / -1; }
|
||||||
|
.responsive-form-grid { align-items: start; }
|
||||||
.form-field { display: grid; gap: 7px; }
|
.form-field { display: grid; gap: 7px; }
|
||||||
.form-label { font-weight: 700; font-size: 13px; color: var(--text-label); }
|
.form-label { font-weight: 700; font-size: 13px; color: var(--text-label); }
|
||||||
.form-help { font-size: 12px; color: var(--muted); }
|
.form-help { font-size: 12px; color: var(--muted); }
|
||||||
input, select, textarea { border: 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); }
|
input:not([type="checkbox"]):not([type="radio"]), 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); }
|
||||||
|
input[type="checkbox"]:not(.toggle-switch-input),
|
||||||
|
input[type="radio"] {
|
||||||
|
accent-color: var(--primary);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input[type="checkbox"]:not(.toggle-switch-input):focus-visible,
|
||||||
|
input[type="radio"]:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
textarea { resize: vertical; }
|
textarea { resize: vertical; }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.form-grid.compact.responsive-form-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.form-grid.two,
|
||||||
|
.form-grid.three,
|
||||||
|
.form-grid.four { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
.form-field:has(input:disabled, select:disabled, textarea:disabled, input[readonly], textarea[readonly]) .form-label { color: var(--control-disabled-label); }
|
.form-field:has(input:disabled, select:disabled, textarea:disabled, input[readonly], textarea[readonly]) .form-label { color: var(--control-disabled-label); }
|
||||||
input:disabled:not([type="checkbox"]):not([type="radio"]),
|
input:disabled:not([type="checkbox"]):not([type="radio"]),
|
||||||
select:disabled,
|
select:disabled,
|
||||||
@@ -33,7 +61,7 @@ textarea[readonly]::placeholder {
|
|||||||
color: var(--control-disabled-placeholder);
|
color: var(--control-disabled-placeholder);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.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 { align-items: center; border: var(--border-line-dark); border-radius: 4px; cursor: pointer; display: inline-flex; font: inherit; font-weight: 700; gap: 7px; justify-content: center; padding: 9px 14px; 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-primary { background: var(--green); border-color: var(--success-border); color: var(--on-accent); }
|
||||||
.btn-ghost { border-color: transparent; background: transparent; box-shadow: none; }
|
.btn-ghost { border-color: transparent; background: transparent; box-shadow: none; }
|
||||||
.btn-danger { background: var(--red); color: var(--on-accent); border-color: var(--danger-border-strong); }
|
.btn-danger { background: var(--red); color: var(--on-accent); border-color: var(--danger-border-strong); }
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
.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); }
|
.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-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); }
|
||||||
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
|
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
|
||||||
|
.block { display: block; }
|
||||||
|
.danger-text { color: var(--danger-text); }
|
||||||
|
.small-text { font-size: .78rem; }
|
||||||
|
.small-note { font-size: 12px; }
|
||||||
.titlebar-spacer { flex: 1; }
|
.titlebar-spacer { flex: 1; }
|
||||||
.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-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 { width: 34px; height: 34px; justify-content: center; border-radius: 4px; cursor: pointer; }
|
||||||
|
|||||||
@@ -653,17 +653,17 @@
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Consistent row-level collection actions for editable DataGrid tables. */
|
/* Consistent, context-aware row actions for tables and DataGrids. */
|
||||||
.data-grid-row-actions {
|
.table-action-group {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(4, 36px);
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-grid-row-action.btn {
|
.table-action-button.btn {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
@@ -672,10 +672,20 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-grid-row-action.btn:disabled {
|
.table-action-button.btn:disabled {
|
||||||
opacity: .45;
|
opacity: .45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-action-button.btn svg {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.data-grid-empty-message {
|
.data-grid-empty-message {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
|
|||||||
+3
-2
@@ -365,11 +365,12 @@ export type OrganizationFunctionActionContext = PlatformRouteContext & {
|
|||||||
|
|
||||||
export type OrganizationFunctionActionContribution = {
|
export type OrganizationFunctionActionContribution = {
|
||||||
id: string;
|
id: string;
|
||||||
label?: string;
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
order?: number;
|
order?: number;
|
||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
render: (context: OrganizationFunctionActionContext) => ReactNode;
|
onClick: (context: OrganizationFunctionActionContext) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OrganizationFunctionActionsUiCapability = {
|
export type OrganizationFunctionActionsUiCapability = {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import Button from "../src/components/Button";
|
||||||
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
|
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
|
||||||
|
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
|
|
||||||
@@ -30,3 +32,37 @@ const emptyAction = renderToStaticMarkup(
|
|||||||
);
|
);
|
||||||
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
|
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
|
||||||
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
|
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
|
||||||
|
|
||||||
|
const contextActions = renderToStaticMarkup(
|
||||||
|
<TableActionGroup
|
||||||
|
actions={[
|
||||||
|
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
|
||||||
|
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
|
||||||
|
{ id: "remove", label: "Remove", icon: <span>R</span>, disabled: true, onClick: noop }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertEqual(buttonCount(contextActions), 2, "table action groups omit actions that do not apply");
|
||||||
|
assertEqual(nonSubmittingButtonCount(contextActions), 2, "table action groups do not submit an enclosing form");
|
||||||
|
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
|
||||||
|
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
|
||||||
|
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
|
||||||
|
|
||||||
|
let propagationStopped = false;
|
||||||
|
let actionCalled = false;
|
||||||
|
runTableAction(
|
||||||
|
{ stopPropagation: () => { propagationStopped = true; } },
|
||||||
|
() => { actionCalled = true; }
|
||||||
|
);
|
||||||
|
assertEqual(propagationStopped, true, "table actions do not trigger clickable row handlers");
|
||||||
|
assertEqual(actionCalled, true, "table actions still invoke their handler");
|
||||||
|
|
||||||
|
const noReorderActions = renderToStaticMarkup(
|
||||||
|
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
|
||||||
|
);
|
||||||
|
assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");
|
||||||
|
|
||||||
|
const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>);
|
||||||
|
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
|
||||||
|
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
||||||
|
assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM");
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(markup: string, pattern: RegExp): number {
|
||||||
|
return markup.match(pattern)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import ExplorerTree from "../src/components/ExplorerTree";
|
||||||
|
import IconButton from "../src/components/IconButton";
|
||||||
|
|
||||||
|
type TestNode = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
children: TestNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function noop() {}
|
||||||
|
|
||||||
|
const alpha: TestNode = { id: "alpha", label: "Alpha", children: [] };
|
||||||
|
const beta: TestNode = { id: "beta", label: "Beta", children: [] };
|
||||||
|
alpha.children.push(beta);
|
||||||
|
beta.children.push(alpha);
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<ExplorerTree
|
||||||
|
nodes={[alpha, alpha]}
|
||||||
|
getNodeId={(node) => node.id}
|
||||||
|
getNodeLabel={(node) => node.label}
|
||||||
|
getNodeChildren={(node) => node.children}
|
||||||
|
activeId="beta"
|
||||||
|
collapsible={false}
|
||||||
|
onOpen={noop}
|
||||||
|
renderNodeContent={(node) => <span>{`node-${node.id}`}</span>}
|
||||||
|
renderNodeActions={(node) => (
|
||||||
|
<IconButton label={`Add below ${node.label}`} icon={<span>+</span>} onClick={noop} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(markup.includes("node-alpha"), "the root node is rendered");
|
||||||
|
assert(markup.includes("node-beta"), "non-collapsible trees render descendants without expansion state");
|
||||||
|
assert(count(markup, /node-alpha/g) === 1, "a cycle does not render an ancestor again");
|
||||||
|
assert(count(markup, /node-beta/g) === 1, "duplicate paths do not duplicate descendants");
|
||||||
|
assert(count(markup, /explorer-tree-toggle-static/g) === 2, "non-collapsible nodes render static tree affordances");
|
||||||
|
assert(!markup.includes('<button type="button" class="explorer-tree-toggle'), "non-collapsible nodes do not expose dead toggle buttons");
|
||||||
|
assert(markup.includes("explorer-tree-node-wrap file-tree-node-wrap has-actions"), "rows reserve a sibling action column only when used");
|
||||||
|
assert(markup.includes('class="explorer-tree-actions" role="group"'), "node actions expose group semantics");
|
||||||
|
assert(markup.includes('aria-label="Add below Alpha"'), "node action controls retain accessible labels");
|
||||||
|
assert(
|
||||||
|
/node-alpha<\/span><\/button><div class="explorer-tree-actions"/.test(markup),
|
||||||
|
"node actions are siblings of the node button instead of nested interactive content"
|
||||||
|
);
|
||||||
|
assert(markup.includes('aria-current="true"'), "the active node is exposed to assistive technology");
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import AdminIconButton from "../src/components/admin/AdminIconButton";
|
||||||
|
import IconButton from "../src/components/IconButton";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
|
function noop() {}
|
||||||
|
|
||||||
|
const iconButtonMarkup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<IconButton
|
||||||
|
label="i18n:govoplan-core.remove.e963907d"
|
||||||
|
icon={<svg><path /></svg>}
|
||||||
|
variant="danger"
|
||||||
|
className="context-action"
|
||||||
|
onClick={noop}
|
||||||
|
/>
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
assert(iconButtonMarkup.includes('type="button"'), "icon buttons default to a non-submitting button");
|
||||||
|
assert(iconButtonMarkup.includes('class="btn btn-danger icon-button context-action"'), "central variants and caller classes are preserved");
|
||||||
|
assert(iconButtonMarkup.includes('aria-label="Remove"'), "the accessible label is translated");
|
||||||
|
assert(iconButtonMarkup.includes('title="Remove"'), "the native title is translated");
|
||||||
|
assert(iconButtonMarkup.includes('class="icon-button-icon" aria-hidden="true"'), "the visible icon is decorative");
|
||||||
|
|
||||||
|
const reasonedMarkup = renderToStaticMarkup(
|
||||||
|
<IconButton label="Clear" icon={<span>X</span>} disabledReason="Permission denied" />
|
||||||
|
);
|
||||||
|
assert(reasonedMarkup.includes("disabled=\"\""), "a disabled reason disables the icon button");
|
||||||
|
assert(reasonedMarkup.includes("disabled-action-tooltip"), "a disabled reason remains discoverable");
|
||||||
|
assert(!reasonedMarkup.includes("disabledReason"), "central-only props are not passed to the DOM");
|
||||||
|
|
||||||
|
const legacyMarkup = renderToStaticMarkup(
|
||||||
|
<AdminIconButton label="Add" icon={<span>+</span>} disabled />
|
||||||
|
);
|
||||||
|
assert(legacyMarkup.includes("icon-button admin-icon-button"), "the legacy wrapper delegates to the neutral component and preserves its class hook");
|
||||||
|
assert(legacyMarkup.includes("disabled=\"\""), "the legacy disabled contract is preserved");
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { PlatformWebModule } from "../src/types";
|
import type {
|
||||||
|
OrganizationFunctionActionContext,
|
||||||
|
OrganizationFunctionActionContribution,
|
||||||
|
PlatformWebModule
|
||||||
|
} from "../src/types";
|
||||||
import {
|
import {
|
||||||
hasUiCapability,
|
hasUiCapability,
|
||||||
moduleInstalled,
|
moduleInstalled,
|
||||||
@@ -67,3 +71,28 @@ assert(routes.join(",") === "/campaigns,/files,/mail", "routes should aggregate
|
|||||||
|
|
||||||
assert(scopeGrants("tenant:*", "calendar:event:read"), "tenant wildcard should grant tenant-level module scopes");
|
assert(scopeGrants("tenant:*", "calendar:event:read"), "tenant wildcard should grant tenant-level module scopes");
|
||||||
assert(!scopeGrants("tenant:*", "system:settings:read"), "tenant wildcard should not grant system scopes");
|
assert(!scopeGrants("tenant:*", "system:settings:read"), "tenant wildcard should not grant system scopes");
|
||||||
|
|
||||||
|
let selectedFunctionId = "";
|
||||||
|
const functionAction: OrganizationFunctionActionContribution = {
|
||||||
|
id: "test.view-assignments",
|
||||||
|
label: "View assignments",
|
||||||
|
icon: "assignments-icon",
|
||||||
|
order: 40,
|
||||||
|
anyOf: ["idm:organization_assignment:read"],
|
||||||
|
onClick: (context) => {
|
||||||
|
selectedFunctionId = context.function.id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
functionAction.onClick({
|
||||||
|
settings: { apiBaseUrl: "", apiKey: "", accessToken: "" },
|
||||||
|
auth: {} as OrganizationFunctionActionContext["auth"],
|
||||||
|
function: {
|
||||||
|
id: "function-1",
|
||||||
|
tenant_id: "tenant-1",
|
||||||
|
organization_unit_id: "unit-1",
|
||||||
|
slug: "test-function",
|
||||||
|
name: "Test function"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert(selectedFunctionId === "function-1", "organization function actions receive their row context");
|
||||||
|
assert(!("render" in functionAction), "organization function actions expose metadata instead of arbitrary rendered controls");
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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 ResourceAccessExplanation, {
|
||||||
|
formatResourceAccessProvenanceDetails,
|
||||||
|
resourceAccessProvenanceKindLabel
|
||||||
|
} from "../src/components/ResourceAccessExplanation";
|
||||||
|
|
||||||
|
const loadingMarkup = renderToStaticMarkup(<ResourceAccessExplanation loading />);
|
||||||
|
assert(
|
||||||
|
loadingMarkup.includes("i18n:govoplan-core.loading_access_explanation.04a7c934"),
|
||||||
|
"loading copy is owned by Core"
|
||||||
|
);
|
||||||
|
|
||||||
|
const explanationMarkup = renderToStaticMarkup(
|
||||||
|
<ResourceAccessExplanation
|
||||||
|
fallbackResourceLabel="Fallback resource"
|
||||||
|
explanation={{
|
||||||
|
user: { id: "user-1", email: "reader@example.test", display_name: "Reader" },
|
||||||
|
resource_type: "campaign",
|
||||||
|
resource_id: "campaign-1",
|
||||||
|
action: "campaigns:campaign:read",
|
||||||
|
provenance: [
|
||||||
|
{
|
||||||
|
kind: "resource",
|
||||||
|
id: "campaign-1",
|
||||||
|
label: "Quarterly notice",
|
||||||
|
source: "campaigns",
|
||||||
|
details: { inherited: true, limit: null }
|
||||||
|
},
|
||||||
|
{ kind: "right" }
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assert(explanationMarkup.includes("Reader"), "the preferred user label is rendered");
|
||||||
|
assert(explanationMarkup.includes("Quarterly notice"), "resource provenance overrides the fallback label");
|
||||||
|
assert(explanationMarkup.includes("campaigns:campaign:read"), "the evaluated action is rendered");
|
||||||
|
assert(explanationMarkup.includes("i18n:govoplan-core.permission.2f81a22d"), "known provenance kinds use Core wording");
|
||||||
|
assert(explanationMarkup.includes("i18n:govoplan-core.no_source.6dcf9723"), "missing sources use Core wording");
|
||||||
|
assert(!explanationMarkup.includes("govoplan-campaign"), "the shared component does not depend on Campaign wording");
|
||||||
|
assert(!explanationMarkup.includes("govoplan-files"), "the shared component does not depend on Files wording");
|
||||||
|
|
||||||
|
const emptyMarkup = renderToStaticMarkup(
|
||||||
|
<ResourceAccessExplanation
|
||||||
|
fallbackResourceLabel="Fallback resource"
|
||||||
|
explanation={{
|
||||||
|
user: { id: "user-1" },
|
||||||
|
resource_type: "file",
|
||||||
|
resource_id: "file-1",
|
||||||
|
action: "files:file:read",
|
||||||
|
provenance: []
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assert(emptyMarkup.includes("Fallback resource"), "the caller fallback labels resources without resource provenance");
|
||||||
|
assert(
|
||||||
|
emptyMarkup.includes("i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e"),
|
||||||
|
"empty evidence uses Core wording"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEqual(resourceAccessProvenanceKindLabel("custom"), "custom", "unknown provenance kinds remain visible");
|
||||||
|
assertEqual(
|
||||||
|
formatResourceAccessProvenanceDetails({ count: 2, inherited: false, source: null, nested: { id: "role-1" } }),
|
||||||
|
'count: 2; inherited: false; source: i18n:govoplan-core.none.6eef6648; nested: {"id":"role-1"}',
|
||||||
|
"provenance details retain the established compact format"
|
||||||
|
);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import SelectionList, { SelectionListItem } from "../src/components/SelectionList";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
|
function noop() {}
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<SelectionList label="i18n:govoplan-core.notifications.753a22b2" className="domain-list" data-source="inbox">
|
||||||
|
<SelectionListItem
|
||||||
|
selected
|
||||||
|
className="domain-row"
|
||||||
|
draggable
|
||||||
|
data-record-id="record-1"
|
||||||
|
onDragStart={noop}
|
||||||
|
onClick={noop}
|
||||||
|
>
|
||||||
|
<strong>Primary content</strong>
|
||||||
|
<small>Supporting content</small>
|
||||||
|
</SelectionListItem>
|
||||||
|
<SelectionListItem selected={false} disabled>
|
||||||
|
Disabled content
|
||||||
|
</SelectionListItem>
|
||||||
|
</SelectionList>
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(markup.includes('role="listbox"'), "the root exposes listbox semantics");
|
||||||
|
assert(markup.includes('class="selection-list domain-list"'), "root caller classes are preserved");
|
||||||
|
assert(markup.includes('data-source="inbox"'), "root native properties pass through");
|
||||||
|
assert(markup.includes('aria-label="Notifications"'), "the accessible label is translated");
|
||||||
|
assert(markup.includes('role="option"'), "items expose option semantics");
|
||||||
|
assert(markup.includes('aria-selected="true"'), "selected state is exposed accessibly");
|
||||||
|
assert(markup.includes('class="selection-list-item is-selected domain-row"'), "selected and domain classes are composed");
|
||||||
|
assert(markup.includes('draggable="true"'), "native draggable properties pass through");
|
||||||
|
assert(markup.includes('data-record-id="record-1"'), "native data properties pass through");
|
||||||
|
assert(markup.includes("<strong>Primary content</strong><small>Supporting content</small>"), "React node content is preserved");
|
||||||
|
assert(markup.includes('type="button"'), "items default to non-submitting buttons");
|
||||||
|
assert(markup.includes('aria-selected="false"'), "unselected state is exposed accessibly");
|
||||||
|
assert(markup.includes('aria-disabled="true"'), "disabled state is exposed accessibly");
|
||||||
|
assert(markup.includes('disabled=""'), "disabled state uses the native button contract");
|
||||||
|
|
||||||
|
const nativeLabelMarkup = renderToStaticMarkup(
|
||||||
|
<SelectionList aria-label="Already translated">
|
||||||
|
<SelectionListItem selected={false}>Content</SelectionListItem>
|
||||||
|
</SelectionList>
|
||||||
|
);
|
||||||
|
assert(nativeLabelMarkup.includes('aria-label="Already translated"'), "native accessible labels pass through unchanged");
|
||||||
@@ -20,18 +20,28 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"tests/data-grid-actions.test.tsx",
|
"tests/data-grid-actions.test.tsx",
|
||||||
"tests/dialog-focus.test.tsx",
|
"tests/dialog-focus.test.tsx",
|
||||||
|
"tests/explorer-tree.test.tsx",
|
||||||
|
"tests/icon-button.test.tsx",
|
||||||
"tests/mail-components.test.tsx",
|
"tests/mail-components.test.tsx",
|
||||||
|
"tests/resource-access-explanation.test.tsx",
|
||||||
|
"tests/selection-list.test.tsx",
|
||||||
"src/components/CredentialPanel.tsx",
|
"src/components/CredentialPanel.tsx",
|
||||||
"src/components/email/EmailAddressInput.tsx",
|
"src/components/email/EmailAddressInput.tsx",
|
||||||
"src/components/PasswordField.tsx",
|
"src/components/PasswordField.tsx",
|
||||||
"src/components/MessageDisplayPanel.tsx",
|
"src/components/MessageDisplayPanel.tsx",
|
||||||
|
"src/components/ResourceAccessExplanation.tsx",
|
||||||
|
"src/components/SelectionList.tsx",
|
||||||
"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/Dialog.tsx",
|
||||||
|
"src/components/ExplorerTree.tsx",
|
||||||
"src/components/dialogInteractions.ts",
|
"src/components/dialogInteractions.ts",
|
||||||
"src/components/dialogStack.ts",
|
"src/components/dialogStack.ts",
|
||||||
"src/components/FormField.tsx",
|
"src/components/FormField.tsx",
|
||||||
"src/components/ToggleSwitch.tsx"
|
"src/components/IconButton.tsx",
|
||||||
|
"src/components/admin/AdminIconButton.tsx",
|
||||||
|
"src/components/ToggleSwitch.tsx",
|
||||||
|
"src/components/table/TableActionGroup.tsx"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user