22 Commits
Author SHA1 Message Date
zemion ae74189588 fix(webui): complete central admin control styles 2026-07-21 14:01:56 +02:00
zemion 09b5009187 feat(webui): extend shared explorer tree actions 2026-07-21 13:58:38 +02:00
zemion 2ca61059dc refactor(webui): describe organization function actions 2026-07-21 13:51:19 +02:00
zemion 865901f090 Centralize shared layout style contracts 2026-07-21 13:46:59 +02:00
zemion 2ac1e64daa refactor(webui): share outside-dismiss behavior 2026-07-21 13:35:09 +02:00
zemion 7526c5ebb2 fix(webui): centralize form control layout 2026-07-21 13:31:32 +02:00
zemion 8e1f64c790 feat(webui): add central selection list 2026-07-21 13:30:39 +02:00
zemion 66e4783d2e feat(webui): add central icon button 2026-07-21 13:23:59 +02:00
zemion 7af86b42eb Centralize explorer work-surface styling 2026-07-21 13:19:20 +02:00
zemion ad202f1267 Centralize shared small-note styling 2026-07-21 13:19:20 +02:00
zemion 6526f37aae feat(webui): centralize resource access explanations 2026-07-21 13:18:30 +02:00
zemion 9dabd9356d Document Core test bootstrap lint exemptions 2026-07-21 13:11:24 +02:00
zemion 6502775bf7 Block connector limited-broadcast targets 2026-07-21 13:02:29 +02:00
zemion b2492b820f Harden private connector address validation 2026-07-21 12:55:01 +02:00
zemion 78d9ae48b2 feat(webui): centralize disabled button reasons 2026-07-21 12:38:45 +02:00
zemion 4cb3e94de3 feat(webui): expose central pagination bar 2026-07-21 12:36:49 +02:00
zemion 9131838b98 refactor(webui): generalize central selection lists 2026-07-21 12:34:35 +02:00
zemion 8e9eb6e1f5 feat(webui): centralize contextual table actions 2026-07-21 12:22:35 +02:00
zemion 249bf63eb8 fix(release): remove unavailable tagged webui packages 2026-07-21 12:22:07 +02:00
zemion 248e3dc70e test(release): isolate catalog contract projection 2026-07-21 12:21:38 +02:00
zemion 230ecf42b0 Enforce deployment security boundaries 2026-07-21 12:10:05 +02:00
zemion 825791e9b0 Harden outbound connector transports 2026-07-21 12:09:44 +02:00
46 changed files with 2750 additions and 361 deletions
+45
View File
@@ -192,15 +192,60 @@ prefer `FILE_STORAGE_*`.
| Setting | Default | Notes |
| --- | --- | --- |
| `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_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
| `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_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
module settings. Do not hardcode them in core; configuration packages should ask
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
+7 -1
View File
@@ -40,10 +40,16 @@ set +a
The command reports all known blockers at once. Production-like/self-hosted
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
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
Use the local production-like wrapper for repeatable rehearsal:
+4
View File
@@ -42,3 +42,7 @@ dev = [
"httpx==0.28.1",
"httpx2>=2.5,<3",
]
[tool.ruff.lint.per-file-ignores]
"tests/test_api_smoke.py" = ["E402"]
"tests/test_module_system.py" = ["E402"]
+169 -1
View File
@@ -262,7 +262,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
audit_event="files.connector_profile.updated",
two_person_approval_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(
key="DATABASE_URL",
@@ -289,6 +289,174 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
two_person_approval_required=True,
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(
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
label="Module package catalog trusted keys",
+101
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
import base64
import json
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from cryptography.fernet import Fernet
@@ -142,6 +144,7 @@ def validate_runtime_configuration(
_validate_async_and_auth_settings(env, runtime, collector)
_validate_cors_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)
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.")
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.")
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:
@@ -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.")
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:
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:
@@ -277,7 +341,28 @@ REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
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
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_SAMESITE=lax
AUTH_COOKIE_DOMAIN=
@@ -321,8 +406,24 @@ CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
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
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
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
+44 -5
View File
@@ -5,6 +5,12 @@ import urllib.request
from dataclasses import dataclass
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)
class HttpFetchResponse:
@@ -40,17 +46,26 @@ def fetch_http(
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
max_bytes: int | None = None,
) -> HttpFetchResponse:
validated_url = validate_outbound_http_url(url, label=label)
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 {}),
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(
status=int(getattr(response, "status", 0)),
headers=dict(response.headers.items()),
body=response.read(),
headers=response_headers,
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",
headers: Mapping[str, str] | None = None,
encoding: str = "utf-8",
max_bytes: int | None = None,
) -> 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)
+375
View File
@@ -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)
+68
View File
@@ -9,14 +9,21 @@ from typing import Any
from fastapi import APIRouter, FastAPI, Request
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.install_config import validate_runtime_configuration
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
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:
@@ -27,6 +34,49 @@ def _slow_request_threshold_ms() -> float:
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(
*,
title: str,
@@ -37,9 +87,27 @@ def create_govoplan_app(
cors_origins: Iterable[str] = (),
health_payload: dict[str, Any] | None = None,
) -> FastAPI:
_validate_production_startup()
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
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")
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
+21
View File
@@ -47,6 +47,27 @@ class Settings(BaseSettings):
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
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")
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
+27 -19
View File
@@ -1811,12 +1811,21 @@ class ApiSmokeTests(unittest.TestCase):
tenant_id = str(login["tenant"]["id"])
env_keys = [
"GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
"GOVOPLAN_TEST_SEAFILE_PASSWORD",
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
"GOVOPLAN_TEST_SMB_PASSWORD",
]
previous_env = {key: os.environ.get(key) for key in env_keys}
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_NEXTCLOUD_TOKEN"] = "super-secret-nextcloud"
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.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):
request = httpx.Request(method, url)
params = kwargs.get("params") or {}
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/":
self.assertEqual(params.get("p"), "/reports/summary.txt")
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Token token-1")
return httpx.Response(
return ConnectorHttpResponse(
200,
json={
{},
json.dumps(
{
"id": "file-1",
"name": "summary.txt",
"size": 14,
"type": "file",
"mtime": 1783425600,
"permission": "r",
},
request=request,
}
).encode(),
)
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/":
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":
return httpx.Response(200, content=b"summary report", headers={"content-type": "text/plain"}, request=request)
return httpx.Response(404, request=request)
return ConnectorHttpResponse(200, {"content-type": "text/plain"}, b"summary report")
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(
"/api/v1/files/connectors/profiles/seafile-dev/import",
headers=headers,
@@ -2259,18 +2269,16 @@ class ApiSmokeTests(unittest.TestCase):
webdav_payload = {"content": b"nextcloud notice", "etag": "\"nextcloud-etag-2\""}
def webdav_request(method: str, url: str, **kwargs):
request = httpx.Request(method, url)
self.assertEqual(method, "GET")
self.assertEqual(url, "http://127.0.0.1:9081/Shared/notice.txt")
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Bearer super-secret-nextcloud")
return httpx.Response(
return ConnectorHttpResponse(
200,
content=webdav_payload["content"],
headers={"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))},
request=request,
{"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))},
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_import = self.client.post(
"/api/v1/files/connectors/profiles/user-nextcloud/import",
headers=headers,
@@ -2292,7 +2300,7 @@ class ApiSmokeTests(unittest.TestCase):
webdav_payload["content"] = b"nextcloud notice updated"
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(
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
headers=headers,
@@ -2314,7 +2322,7 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(synced["file"]["source_revision"], "\"nextcloud-etag-3\"")
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(
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
headers=headers,
+71 -1
View File
@@ -4,8 +4,9 @@ import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.testclient import TestClient
from govoplan_core.audit.logging import audit_event, audit_operation_context
@@ -142,6 +143,75 @@ class CoreEventTests(unittest.TestCase):
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:
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
try:
+160 -1
View File
@@ -1,8 +1,22 @@
from __future__ import annotations
import io
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):
@@ -22,6 +36,151 @@ class HttpFetchTests(unittest.TestCase):
with self.assertRaises(ValueError):
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__":
unittest.main()
+43
View File
@@ -45,7 +45,9 @@ class InstallConfigTests(unittest.TestCase):
"CELERY_ENABLED": "true",
"REDIS_URL": "redis://redis.example.internal:6379/0",
"CORS_ORIGINS": "https://govoplan.example.org",
"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.org",
"AUTH_COOKIE_SECURE": "true",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
"FILE_STORAGE_BACKEND": "local",
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
},
@@ -75,6 +77,35 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("DATABASE_URL", 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:
self_hosted = env_template(profile="self-hosted")
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("MASTER_KEY_B64=<generate", 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.assertNotIn("MASTER_KEY_B64=<generate", 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__":
+15 -21
View File
@@ -6,13 +6,13 @@ import base64
import json
import os
import re
import runpy
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
import textwrap
import tomllib
import unittest
from dataclasses import replace
@@ -3079,27 +3079,21 @@ finally:
self.assertEqual("stable", validation["channel"])
def test_release_catalog_generator_reads_manifest_interface_contracts(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-release-catalog-generator-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
result = subprocess.run(
[
sys.executable,
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "generate-release-catalog.py"),
"--version",
"0.1.7",
"--catalog-output",
str(catalog_path),
],
cwd=str(Path(__file__).resolve().parents[1]),
env={**os.environ.copy(), "GOVOPLAN_CORE_ROOT": str(Path(__file__).resolve().parents[1])},
text=True,
capture_output=True,
check=False,
generator = runpy.run_path(
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "release" / "generate-release-catalog.py"),
run_name="govoplan_release_catalog_contract_test",
)
generated_at = generator["datetime"].now(tz=generator["UTC"])
catalog = generator["_catalog_payload"](
version="0.1.9",
tag="v0.1.9",
channel="test",
sequence=1,
generated_at=generated_at,
expires_at=generated_at + generator["timedelta"](days=1),
repository_base="git+ssh://git@example.test/add-ideas",
public_base_url="https://example.test",
)
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"]}
self.assertIn({"name": "files.campaign_attachments", "version": "0.1.6"}, modules["files"]["provides_interfaces"])
+5 -1
View File
@@ -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: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: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-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": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
-2
View File
@@ -24,7 +24,6 @@
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-addresses.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
@@ -32,7 +31,6 @@
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
"@govoplan/notifications-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-notifications.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
+35
View File
@@ -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
}));
}
+34
View File
@@ -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;
};
+10 -3
View File
@@ -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) {
return <button className={`btn btn-${variant} ${className}`} {...props} />;
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
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>;
}
+4 -23
View File
@@ -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 useOutsideDismiss from "../hooks/useOutsideDismiss";
import { i18nMessage } from "../i18n/LanguageContext";
type ColorPickerFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
value: string;
@@ -20,27 +22,6 @@ function normalizeColor(value: string): string {
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({
value,
onChange,
@@ -51,7 +32,7 @@ export default function ColorPickerField({
...props
}: ColorPickerFieldProps) {
const [open, setOpen] = useState(false);
const rootRef = useOutsideClose(open, () => setOpen(false));
const rootRef = useOutsideDismiss(open, () => setOpen(false));
const inputRef = useRef<HTMLInputElement | null>(null);
const normalizedValue = normalizeColor(value || "");
const previewColor = HEX_PATTERN.test(normalizedValue) ? normalizedValue : "transparent";
+2 -22
View File
@@ -1,5 +1,6 @@
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-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"> & {
value: string;
@@ -48,32 +49,11 @@ function combineDateTime(date: string, time: string): string {
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) {
const selectedDate = parseDate(value);
const [open, setOpen] = useState(false);
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);
useEffect(() => {
+99 -60
View File
@@ -10,15 +10,13 @@ export type ExplorerTreeNodeContext = {
disabled: boolean;
};
export type ExplorerTreeProps<T> = {
type ExplorerTreeCommonProps<T> = {
nodes: T[];
getNodeId: (node: T) => string;
getNodeLabel: (node: T) => string;
getNodeChildren: (node: T) => T[];
activeId?: string;
expandedIds: Set<string>;
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
disabled?: boolean;
depth?: number;
className?: string;
@@ -29,6 +27,7 @@ export type ExplorerTreeProps<T> = {
nodeButtonBaseClassName?: string;
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
renderNodeActions?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined;
getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
@@ -41,15 +40,37 @@ export type ExplorerTreeProps<T> = {
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
};
export default function ExplorerTree<T>({
type CollapsibleExplorerTreeProps<T> = {
collapsible?: true;
expandedIds: ReadonlySet<string>;
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
};
type StaticExplorerTreeProps = {
collapsible: false;
expandedIds?: never;
onToggle?: never;
};
export type ExplorerTreeProps<T> = ExplorerTreeCommonProps<T> & (
CollapsibleExplorerTreeProps<T> | StaticExplorerTreeProps
);
type SafeNode<T> = {
id: string;
node: T;
};
const EMPTY_EXPANDED_IDS: ReadonlySet<string> = new Set();
export default function ExplorerTree<T>(props: ExplorerTreeProps<T>) {
const {
nodes,
getNodeId,
getNodeLabel,
getNodeChildren,
activeId = "",
expandedIds,
onOpen,
onToggle,
disabled = false,
depth = 1,
className = "",
@@ -60,6 +81,7 @@ export default function ExplorerTree<T>({
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
renderToggleIcon,
renderNodeContent,
renderNodeActions,
getNodeWrapClassName,
getNodeWrapStyle,
getNodeButtonClassName,
@@ -70,19 +92,40 @@ export default function ExplorerTree<T>({
onDragOver,
onDragLeave,
onDrop
}: ExplorerTreeProps<T>) {
if (nodes.length === 0) return null;
} = 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, className].filter(Boolean).join(" ")}>
{nodes.map((node) => {
const nodeId = getNodeId(node);
<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 = children.length > 0;
const expanded = expandedIds.has(nodeId);
const hasChildren = pathSafeNodes(children, nextAncestorIds).length > 0;
const expanded = collapsible ? expandedIds.has(nodeId) : hasChildren;
const active = activeId === nodeId;
const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled };
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}>
@@ -90,73 +133,69 @@ export default function ExplorerTree<T>({
className={[
nodeWrapBaseClassName,
active ? "is-active" : "",
getNodeWrapClassName?.(node, context) ?? ""].
filter(Boolean).join(" ")}
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
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}>
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
>
{collapsible ? (
<button
type="button"
className={toggleBaseClassName}
onClick={(event) => {
event.stopPropagation();
if (hasChildren) onToggle(node, context);
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}>
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}>
disabled={disabled}
aria-current={active ? "true" : undefined}
>
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
</button>
</div>
{hasChildren && expanded &&
<ExplorerTree
nodes={children}
getNodeId={getNodeId}
getNodeLabel={getNodeLabel}
getNodeChildren={getNodeChildren}
activeId={activeId}
expandedIds={expandedIds}
onOpen={onOpen}
onToggle={onToggle}
disabled={disabled}
depth={depth + 1}
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>);
{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)
})}
</div>);
>
{nodeActions}
</div>
)}
</div>
{hasChildren && expanded && renderLevel(children, currentDepth + 1, nextAncestorIds)}
</div>
);
})}
</div>
);
}
return renderLevel(nodes, depth, new Set(), true);
}
+31
View File
@@ -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);
}
+67
View File
@@ -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>
);
}
+5 -26
View File
@@ -1,29 +1,8 @@
import type { ReactNode } from "react";
import Button from "../Button";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
import IconButton, { type IconButtonProps } from "../IconButton";
type Props = {
label: string;
icon: ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: "primary" | "secondary" | "ghost" | "danger";
};
export type AdminIconButtonProps = IconButtonProps;
export default function AdminIconButton({ label, icon, onClick, disabled = false, variant = "secondary" }: Props) {
const { translateText } = usePlatformLanguage();
const translatedLabel = translateText(label);
return (
<Button
type="button"
variant={variant}
className="admin-icon-button"
aria-label={translatedLabel}
title={translatedLabel}
disabled={disabled}
onClick={onClick}
>
{icon}
</Button>
);
/** @deprecated Prefer the neutral IconButton export for new consumers. */
export default function AdminIconButton({ className = "", ...props }: AdminIconButtonProps) {
return <IconButton {...props} className={["admin-icon-button", className].filter(Boolean).join(" ")} />;
}
@@ -1,9 +1,10 @@
import { usePlatformLanguage } from "../../i18n/LanguageContext";
import type { ReactNode } from "react";
import { translateReactNode, usePlatformLanguage } from "../../i18n/LanguageContext";
type Option = {
id: string;
label: string;
description?: string | null;
label: ReactNode;
description?: ReactNode;
disabled?: boolean;
};
@@ -37,8 +38,8 @@ export default function AdminSelectionList({
}} />
<span>
<strong>{translateText(option.label)}</strong>
{option.description && <small>{translateText(option.description)}</small>}
<strong>{translateReactNode(option.label, translateText)}</strong>
{option.description && <small>{translateReactNode(option.description, translateText)}</small>}
</span>
</label>
)}
+69 -72
View File
@@ -2,7 +2,7 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type
import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
import StatusBadge from "../StatusBadge";
import Button from "../Button";
import TableActionGroup from "./TableActionGroup";
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
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,
pageSize,
totalRows,
pageCount,
pageSizeOptions,
pageCount = Math.max(1, Math.ceil(totalRows / pageSize)),
pageSizeOptions = [10, 25, 50, 100],
disabled = false,
className = "",
ariaLabel = "i18n:govoplan-core.table_pagination.3665bd76",
onPageChange,
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 last = Math.min(totalRows, page * pageSize);
const options = [...new Set([...pageSizeOptions, pageSize])].filter((value) => value > 0).sort((a, b) => a - b);
const { translateText } = usePlatformLanguage();
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>
<label className="data-grid-page-size">
<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",
moveDownLabel = "i18n:govoplan-core.move_row_down.8e2b5957"
}: DataGridRowActionsProps) {
const { translateText } = usePlatformLanguage();
const translatedAddLabel = translateText(addLabel);
const translatedRemoveLabel = translateText(removeLabel);
const translatedMoveUpLabel = translateText(moveUpLabel);
const translatedMoveDownLabel = translateText(moveDownLabel);
return (
<div className="data-grid-row-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedAddLabel}
title={translatedAddLabel}
disabled={disabled}
onClick={onAddBelow}>
<Plus size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveUpLabel}
title={translatedMoveUpLabel}
disabled={disabled || !onMoveUp}
onClick={() => onMoveUp?.()}>
<ArrowUp size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveDownLabel}
title={translatedMoveDownLabel}
disabled={disabled || !onMoveDown}
onClick={() => onMoveDown?.()}>
<ArrowDown size={16} aria-hidden="true" />
</Button>
<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>);
<TableActionGroup
className="data-grid-row-actions"
actions={[
{
id: "add",
label: addLabel,
icon: <Plus size={16} aria-hidden="true" />,
variant: "primary",
disabled,
onClick: onAddBelow
},
{
id: "move-up",
label: moveUpLabel,
icon: <ArrowUp size={16} aria-hidden="true" />,
applicable: Boolean(onMoveUp),
disabled,
onClick: () => onMoveUp?.()
},
{
id: "move-down",
label: moveDownLabel,
icon: <ArrowDown size={16} aria-hidden="true" />,
applicable: Boolean(onMoveDown),
disabled,
onClick: () => onMoveDown?.()
},
{
id: "remove",
label: removeLabel,
icon: <Trash2 size={16} aria-hidden="true" />,
variant: "danger",
disabled: disabled || removeDisabled,
onClick: onRemove
}
]}
/>);
}
@@ -916,22 +917,18 @@ export function DataGridEmptyAction({
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
const { translateText } = usePlatformLanguage();
const translatedLabel = translateText(label);
return (
<div className="data-grid-row-actions data-grid-empty-row-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedLabel}
title={translatedLabel}
disabled={disabled}
onClick={onAdd}>
<Plus size={16} aria-hidden="true" />
</Button>
</div>);
<TableActionGroup
className="data-grid-row-actions data-grid-empty-row-actions"
actions={[{
id: "add",
label,
icon: <Plus size={16} aria-hidden="true" />,
variant: "primary",
disabled,
onClick: onAdd
}]}
/>);
}
@@ -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>
);
}
+30
View File
@@ -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;
}
+24
View File
@@ -6,12 +6,14 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.about.6b21fb79": "About",
"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_explanation.75ee7f62": "Access explanation",
"i18n:govoplan-core.accessible_to_you.266eca4e": "Accessible to you",
"i18n:govoplan-core.account_context.5bb0a918": "Account context",
"i18n:govoplan-core.account_display_name.03ed8fc5": "Account display name",
"i18n:govoplan-core.account_id.c5e0db28": "Account ID",
"i18n:govoplan-core.account_settings.82cf8a5f": "Account settings",
"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_n.bc500ee1": "Action or review severity when this rule finds no matching file.",
"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_integrity_check_failed.1d8f6b89": "entry integrity check failed",
"i18n:govoplan-core.equals.09b6a6dc": "Equals",
"i18n:govoplan-core.evidence.8487d192": "Evidence",
"i18n:govoplan-core.excel_spreadsheet.7107fea2": "Excel spreadsheet",
"i18n:govoplan-core.expand.9869e506": "Expand",
"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_than.1d3d412a": "Less than",
"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_campaigns.43ea3afa": "Loading campaigns...",
"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_page.4bfc194b": "Next page",
"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_address_added_yet.809c4247": "No address added yet.",
"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_readable_body_content.37643a01": "No readable body content.",
"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_values_are_configured_for_this_column.16e935e8": "No values are configured for this column.",
"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_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.owner.89ff3122": "Owner",
"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.page.fb06270f": "Page",
@@ -351,6 +358,7 @@ export const generatedTranslations: PlatformTranslations = {
"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.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_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.",
@@ -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.png_image.6715d4b8": "PNG image",
"i18n:govoplan-core.preferences_saved.c8cd3501": "Preferences saved.",
"i18n:govoplan-core.policy.0b779a05": "Policy",
"i18n:govoplan-core.policies.8d611849": "Policies",
"i18n:govoplan-core.port.fe035157": "Port",
"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.required.eed6bfb4": "Required",
"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.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.review_send.1627617d": "Review & Send",
"i18n:govoplan-core.role.b5b4a5a2": "Role",
"i18n:govoplan-core.rows_per_page.af2f9c1b": "Rows per page",
"i18n:govoplan-core.sa.50cf95ce": "Sa",
"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.session_expired.b828190e": "Session expired",
"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.settings.c7f73bb5": "Settings",
"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.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_explanation.75ee7f62": "Zugriffserklärung",
"i18n:govoplan-core.accessible_to_you.266eca4e": "Accessible to you",
"i18n:govoplan-core.account_context.5bb0a918": "Account context",
"i18n:govoplan-core.account_display_name.03ed8fc5": "Account display name",
"i18n:govoplan-core.account_id.c5e0db28": "Account ID",
"i18n:govoplan-core.account_settings.82cf8a5f": "Account settings",
"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_n.bc500ee1": "Action or review severity when this rule finds no matching file.",
"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_integrity_check_failed.1d8f6b89": "entry integrity check failed",
"i18n:govoplan-core.equals.09b6a6dc": "Equals",
"i18n:govoplan-core.evidence.8487d192": "Nachweise",
"i18n:govoplan-core.excel_spreadsheet.7107fea2": "Excel spreadsheet",
"i18n:govoplan-core.expand.9869e506": "Expand",
"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_than.1d3d412a": "Less than",
"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_campaigns.43ea3afa": "Loading campaigns...",
"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_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.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_address_added_yet.809c4247": "No address added yet.",
"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_readable_body_content.37643a01": "No readable body content.",
"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_values_are_configured_for_this_column.16e935e8": "No values are configured for this column.",
"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_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.owner.89ff3122": "Eigentümer",
"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.page.fb06270f": "Page",
@@ -908,6 +927,7 @@ export const generatedTranslations: PlatformTranslations = {
"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.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_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.",
@@ -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.png_image.6715d4b8": "PNG image",
"i18n:govoplan-core.preferences_saved.c8cd3501": "Einstellungen gespeichert.",
"i18n:govoplan-core.policy.0b779a05": "Richtlinie",
"i18n:govoplan-core.policies.8d611849": "Richtlinien",
"i18n:govoplan-core.port.fe035157": "Port",
"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.required.eed6bfb4": "Required",
"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.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.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.sa.50cf95ce": "Sa",
"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.session_expired.b828190e": "Sitzung abgelaufen",
"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.settings.c7f73bb5": "Einstellungen",
"i18n:govoplan-core.show_content.0528d8d2": "Show content",
+13 -2
View File
@@ -24,6 +24,7 @@ export type {
MockMailboxMessageResponse
} from "./api/mailContracts";
export * from "./api/privacyRetention";
export * from "./api/resourceAccess";
export * from "./platform/modules";
export * from "./platform/ModuleContext";
export * from "./platform/moduleEvents";
@@ -43,10 +44,12 @@ export { default as ActionBlockerHint } from "./components/ActionBlockerHint";
export type { ActionBlockerReason } from "./components/ActionBlockerHint";
export { default as AdvancedOptionsPanel } from "./components/AdvancedOptionsPanel";
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 AdminSelectionList } from "./components/admin/AdminSelectionList";
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
export { default as Button } from "./components/Button";
export type { ButtonProps } from "./components/Button";
export { default as Card } from "./components/Card";
export { default as ConfirmDialog } from "./components/ConfirmDialog";
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 { default as HoverTooltip } 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 LoadingIndicator } from "./components/LoadingIndicator";
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 { default as PageTitle } from "./components/PageTitle";
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 { PolicyRow, PolicySection, PolicyTable } from "./components/PolicyTable";
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 { default as SegmentedControl } 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 Stepper } from "./components/Stepper";
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 { default as FieldLabel } from "./components/help/FieldLabel";
export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid";
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } 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 PublicLandingPage } from "./features/auth/PublicLandingPage";
+1 -1
View File
@@ -1,6 +1,6 @@
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text-strong); }
.status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); }
.status-blocked, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
.status-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-inactive, .status-locked { background: var(--status-neutral-bg); color: var(--text-soft); }
+513 -16
View File
@@ -117,10 +117,6 @@
font-size: 12px;
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-attachments-input {
min-width: 150px;
@@ -957,16 +953,7 @@
align-self: start;
}
.admin-icon-actions {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 36px;
justify-content: end;
gap: 5px;
width: 100%;
}
.admin-icon-button.btn {
.icon-button.btn {
display: inline-grid;
place-items: center;
width: 36px;
@@ -975,7 +962,7 @@
padding: 0;
}
.admin-icon-button.btn svg {
.icon-button.btn svg {
width: 17px;
height: 17px;
}
@@ -985,11 +972,19 @@
gap: 14px;
}
.admin-form-grid > .wide {
grid-column: 1 / -1;
}
.admin-form-grid.two-columns,
.admin-assignment-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-form-grid.three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-assignment-grid {
display: grid;
gap: 18px;
@@ -1032,8 +1027,9 @@
color: var(--muted);
}
@media (max-width: 850px) {
@media (max-width: 900px) {
.admin-form-grid.two-columns,
.admin-form-grid.three-columns,
.admin-assignment-grid,
.admin-permission-groups {
grid-template-columns: 1fr;
@@ -1803,6 +1799,27 @@
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 {
border-color: var(--danger-border-deep) !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. */
.explorer-tree-children {
display: grid;
@@ -2031,6 +2256,10 @@
border-radius: 9px;
}
.explorer-tree-node-wrap.has-actions {
grid-template-columns: 26px minmax(0, 1fr) auto;
}
.explorer-tree-node,
.explorer-tree-toggle {
border: 0;
@@ -2053,6 +2282,11 @@
opacity: .55;
}
.explorer-tree-toggle.explorer-tree-toggle-static {
cursor: default;
pointer-events: none;
}
.explorer-tree-node {
display: flex;
align-items: center;
@@ -2072,6 +2306,44 @@
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:focus-within,
.explorer-tree-node-wrap.is-active {
@@ -2300,6 +2572,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 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
@@ -2789,3 +3110,179 @@
@keyframes file-drop-progress-spin {
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;
}
}
+31 -3
View File
@@ -1,10 +1,38 @@
.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-label { font-weight: 700; font-size: 13px; color: var(--text-label); }
.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; }
@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); }
input:disabled:not([type="checkbox"]):not([type="radio"]),
select:disabled,
@@ -33,7 +61,7 @@ textarea[readonly]::placeholder {
color: var(--control-disabled-placeholder);
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-ghost { border-color: transparent; background: transparent; box-shadow: none; }
.btn-danger { background: var(--red); color: var(--on-accent); border-color: var(--danger-border-strong); }
+4
View File
@@ -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); }
.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); }
.block { display: block; }
.danger-text { color: var(--danger-text); }
.small-text { font-size: .78rem; }
.small-note { font-size: 12px; }
.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-icon-link { width: 34px; height: 34px; justify-content: center; border-radius: 4px; cursor: pointer; }
+16 -6
View File
@@ -653,17 +653,17 @@
padding: 8px 10px;
}
/* Consistent row-level collection actions for editable DataGrid tables. */
.data-grid-row-actions {
display: grid;
grid-template-columns: repeat(4, 36px);
/* Consistent, context-aware row actions for tables and DataGrids. */
.table-action-group {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: end;
gap: 4px;
width: 100%;
}
.data-grid-row-action.btn {
.table-action-button.btn {
display: inline-grid;
place-items: center;
width: 36px;
@@ -672,10 +672,20 @@
padding: 0;
}
.data-grid-row-action.btn:disabled {
.table-action-button.btn:disabled {
opacity: .45;
}
.table-action-button.btn svg {
width: 17px;
height: 17px;
}
.table-action-icon {
display: inline-grid;
place-items: center;
}
.data-grid-empty-message {
color: var(--muted);
min-height: 64px;
+3 -2
View File
@@ -365,11 +365,12 @@ export type OrganizationFunctionActionContext = PlatformRouteContext & {
export type OrganizationFunctionActionContribution = {
id: string;
label?: string;
label: string;
icon: ReactNode;
order?: number;
anyOf?: string[];
allOf?: string[];
render: (context: OrganizationFunctionActionContext) => ReactNode;
onClick: (context: OrganizationFunctionActionContext) => void;
};
export type OrganizationFunctionActionsUiCapability = {
+36
View File
@@ -3,7 +3,9 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
}
import { renderToStaticMarkup } from "react-dom/server";
import Button from "../src/components/Button";
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
function noop() {}
@@ -30,3 +32,37 @@ const emptyAction = renderToStaticMarkup(
);
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
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");
+55
View File
@@ -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");
+40
View File
@@ -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");
+30 -1
View File
@@ -1,4 +1,8 @@
import type { PlatformWebModule } from "../src/types";
import type {
OrganizationFunctionActionContext,
OrganizationFunctionActionContribution,
PlatformWebModule
} from "../src/types";
import {
hasUiCapability,
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:*", "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"
);
+52
View File
@@ -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");
+11 -1
View File
@@ -20,18 +20,28 @@
"include": [
"tests/data-grid-actions.test.tsx",
"tests/dialog-focus.test.tsx",
"tests/explorer-tree.test.tsx",
"tests/icon-button.test.tsx",
"tests/mail-components.test.tsx",
"tests/resource-access-explanation.test.tsx",
"tests/selection-list.test.tsx",
"src/components/CredentialPanel.tsx",
"src/components/email/EmailAddressInput.tsx",
"src/components/PasswordField.tsx",
"src/components/MessageDisplayPanel.tsx",
"src/components/ResourceAccessExplanation.tsx",
"src/components/SelectionList.tsx",
"src/components/mail/MailServerSettingsPanel.tsx",
"src/components/Button.tsx",
"src/components/DismissibleAlert.tsx",
"src/components/Dialog.tsx",
"src/components/ExplorerTree.tsx",
"src/components/dialogInteractions.ts",
"src/components/dialogStack.ts",
"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"
]
}