Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d37aa527f | ||
|
|
6591aaa3fd | ||
|
|
dc1f244f17 |
@@ -138,6 +138,7 @@ dist
|
||||
|
||||
# Local WebUI test/build scratch directories
|
||||
.component-test-build/
|
||||
.component-test-build-*/
|
||||
.file-drop-test-build/
|
||||
.module-test-build/
|
||||
.policy-test-build/
|
||||
|
||||
@@ -26,18 +26,27 @@ cd /mnt/DATA/git/govoplan-core
|
||||
|
||||
For WebUI checks:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan
|
||||
./devkit doctor --repo core
|
||||
./devkit check --profile ui --repo core --dry-run
|
||||
./devkit check --profile ui --repo core
|
||||
```
|
||||
|
||||
For an individually selected component batch (one compilation):
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:mail-components
|
||||
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-capabilities
|
||||
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-permutations
|
||||
npm run test:components -- mail-components page-layout
|
||||
npm run test:module-capabilities
|
||||
npm run test:module-permutations
|
||||
```
|
||||
|
||||
Run the consolidated focused check when a change touches module discovery, optional integrations, shared mail components, or mailbox listing:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan
|
||||
tools/checks/check-focused.sh
|
||||
./devkit check --profile full
|
||||
```
|
||||
|
||||
## Working Rules
|
||||
|
||||
@@ -33,6 +33,8 @@ Canonical policy documents live in `docs/`:
|
||||
- [ACCESS_RBAC_MODEL.md](docs/ACCESS_RBAC_MODEL.md)
|
||||
- [GOVERNANCE_MODEL.md](docs/GOVERNANCE_MODEL.md)
|
||||
- [MODULE_ARCHITECTURE.md](docs/MODULE_ARCHITECTURE.md)
|
||||
- [INTEGRITY_PERFORMANCE_CONTRACT.md](docs/INTEGRITY_PERFORMANCE_CONTRACT.md)
|
||||
- [TABULAR_SOURCE_CONTRACT.md](docs/TABULAR_SOURCE_CONTRACT.md)
|
||||
- [DEPLOYMENT_OPERATOR_GUIDE.md](docs/DEPLOYMENT_OPERATOR_GUIDE.md)
|
||||
- [CODEX_WORKFLOW.md](docs/CODEX_WORKFLOW.md)
|
||||
|
||||
|
||||
+3
-1
@@ -26,7 +26,9 @@ from govoplan_core.tenancy.scope import scope_registry
|
||||
|
||||
config = context.config
|
||||
database_url = config.attributes.get("database_url") or settings.database_url
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
# Alembic stores options through ConfigParser: escape only its interpolation
|
||||
# syntax so URL-encoded credentials/socket paths reach SQLAlchemy unchanged.
|
||||
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
|
||||
|
||||
if config.config_file_name is not None:
|
||||
# Migrations can run inside the long-lived application process when module
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Disposable resource-bounded operations
|
||||
|
||||
`security.bounded_process.run_bounded_operation` runs a trusted, importable,
|
||||
module-level `bytes -> bytes` function in a fresh interpreter. Core owns the
|
||||
process lifecycle, not the business parser. Owners retain authorization,
|
||||
sessions, provider reads, idempotency and persistence in the parent and pass
|
||||
only explicit bounded data. Never accept the operation, module or source path
|
||||
from a client. Use `security.worker_payload` for typed values; it does not use
|
||||
pickle, arbitrary constructors or JSON object hooks.
|
||||
|
||||
The runner requires POSIX process groups, `waitid(WNOWAIT)` and resource limits.
|
||||
Unsupported controls fail closed; there is no in-process fallback. The child
|
||||
uses `-I -B`, a fixed minimal environment, `/` as working directory, closed
|
||||
inherited descriptors and a new process session. Limits are installed before
|
||||
the owning module is imported. Installed dependencies must support isolated
|
||||
Python imports; development `PYTHONPATH` alone is insufficient.
|
||||
|
||||
`ProcessLimits` specifies wall-clock seconds (including child startup), CPU
|
||||
seconds, virtual address space, input/output pipe bytes and maximum regular-file
|
||||
size. Defaults are 10 seconds wall/CPU, 256 MiB address space, 8 MiB input and
|
||||
output, and no regular-file output. Wall/CPU limits are at most 600 seconds,
|
||||
memory 64 MiB–8 GiB, pipe limits 1 byte–256 MiB, and file size 0–2 GiB. Owners
|
||||
must document their tighter functional limits; a transport cap does not replace
|
||||
row, archive expansion, item-count or artifact limits.
|
||||
|
||||
Admission is non-queuing. `GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY` defaults to 1
|
||||
(range 1–16) and applies across these operations **within each API/worker
|
||||
process**. Multiply capacity and memory budgets by the number of API/worker
|
||||
processes when sizing an installation. This is not a fleet-wide semaphore,
|
||||
cgroup quota, filesystem/network sandbox or permission to run arbitrary code.
|
||||
`RLIMIT_FSIZE` is per file, not a total disk quota. Owners creating staged files
|
||||
must enforce cumulative quotas and clean up their own private directories.
|
||||
|
||||
Owners preparing bounded local snapshots can enter
|
||||
`bounded_operation_admission()` before preparation and pass its token as
|
||||
`admission=` to the runner. This reuses shared capacity rather than reserving a
|
||||
second slot. Tokens belong to their active context, thread and process; expired,
|
||||
cross-thread and overlapping reuse fail. Preparation exceptions release the
|
||||
slot without launching a child. Never hold admission while waiting for a user;
|
||||
parent-side preparation still requires explicit I/O and byte bounds.
|
||||
|
||||
The parent concurrently drains stdout/stderr while writing input. Output is
|
||||
bounded during reading, stderr is discarded and capped at 64 KiB, and raw child
|
||||
tracebacks are never returned. Every success, exception, timeout, cancellation
|
||||
and callback failure kills the owned process group before reaping its leader,
|
||||
including descendants which close their inherited pipes. A module-level child
|
||||
handler must return bytes; it must not print logs/progress to stdout.
|
||||
|
||||
The optional `cancelled` callback runs in the parent at most roughly every
|
||||
50 ms while waiting. It must be fast and must not return an awaitable. It may
|
||||
also service a module-owned bounded progress protocol; exceptions terminate
|
||||
the child and propagate. There is no fabricated progress for killed work.
|
||||
`ProcessBudgetError.code` distinguishes busy, cancelled, timeout, CPU, memory,
|
||||
input/output limits, unavailable controls and worker failure. Owners map these
|
||||
to their existing structured diagnostics and recovery semantics.
|
||||
|
||||
The private typed-data codec supports null, booleans, strings, bytes, integers,
|
||||
floats, Decimal, UUID, date/time/datetime, lists, tuples and string-keyed maps.
|
||||
It rejects unsupported objects, malformed/trailing bytes, duplicate keys,
|
||||
excess depth (64) and node counts (1,000,000). Operation DTOs remain owner
|
||||
contracts and require owner validation. Do not persist this private wire format
|
||||
or use it as a public API.
|
||||
|
||||
Tests use real child processes for catastrophic regex, memory exhaustion,
|
||||
noisy output, exact limits, cancellation, closed-pipe hangs and descendant
|
||||
cleanup. These are local process regression tests, not production concurrent
|
||||
load certification. Operators still need target Linux/cgroup, cancellation,
|
||||
worker-count, memory and disk-quota evidence before raising concurrency.
|
||||
|
||||
## Deutsche Betriebszusammenfassung
|
||||
|
||||
Rechenintensive, vertrauenswürdige Moduloperationen laufen in einem frischen
|
||||
Prozess mit harten Laufzeit-, CPU-, Speicher- und Ausgabegrenzen. Berechtigungen,
|
||||
Sitzungen, Zugangsdaten und Datenbankänderungen bleiben im Hauptprozess. Fehlende
|
||||
Betriebssystemkontrollen führen zu einer Diagnose, nicht zu ungeschützter
|
||||
Ausführung. `GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY` begrenzt die gemeinsame
|
||||
Zulassung je API-/Worker-Prozess, standardmäßig auf 1. Mehrere Prozesse haben
|
||||
jeweils eigene Grenzen; systemweite Speicher- und Festplattenquoten müssen
|
||||
Betreiber zusätzlich konfigurieren und auf der Zielinstallation prüfen. Die
|
||||
Schnittstelle ist keine Sandbox für beliebigen Code. Modul-Dokumentation nennt
|
||||
die jeweiligen fachlichen Grenzen, Fortschritts- und Wiederholungsregeln.
|
||||
@@ -6,6 +6,23 @@ occupies its normal space. `Card bodyLayout="table"` provides an explicit
|
||||
zero-inset surface, including with loading wrappers and padded notices. The
|
||||
Organizations/IDM browser fixtures assert row geometry, not just outer shells.
|
||||
|
||||
The containing card removes its body padding; tables stay at `width: 100%` and
|
||||
`max-width: 100%`. Do not cancel padding with negative margins or an expanded
|
||||
`calc()` width: the table's maximum width correctly clamps that expansion, and
|
||||
the result is a visible gap. Existing cards with only a DataGrid, admin table
|
||||
surface, or connection tree inherit the same zero-inset geometry, directly or
|
||||
through a `LoadingFrame`. Its loading overlay does not count as content and
|
||||
stays within the table body. Mixed prose/form content keeps the usual insets;
|
||||
choose `bodyLayout="table"` explicitly when combining a full-width table with
|
||||
separately padded notices. Ordinary standalone tables retain their border.
|
||||
|
||||
Deutsch: Tabellenkarten entfernen den Innenabstand am Karteninhalt. Die Tabelle
|
||||
bleibt bei 100 Prozent Breite; negative Ränder und verbreiternde `calc()`-Werte
|
||||
sind nicht erforderlich. Das gilt auch während des Ladens. Karten mit Text
|
||||
oder Formularen behalten ihren Innenabstand. Für eine randlose Tabelle neben
|
||||
einem separat gepolsterten Hinweis ist `bodyLayout="table"` ausdrücklich zu
|
||||
setzen; alleinstehende Tabellen behalten ihren Rahmen.
|
||||
|
||||
`DataGrid` turns every declared track into a deterministic pixel layout after
|
||||
its container has a measurable width. The same contract is used on initial
|
||||
layout, container resize, persisted-layout restore, and pointer/keyboard resize.
|
||||
@@ -23,6 +40,21 @@ layout, container resize, persisted-layout restore, and pointer/keyboard resize.
|
||||
- `maxWidth` bounds direct user growth and free/constrained compensation. In a
|
||||
cover layout it is a preferred maximum: passive tracks may exceed it when
|
||||
that is necessary to keep the table flush with its container.
|
||||
- `preferredMaxWidth` is a presentation-only automatic-fit ceiling. It retains
|
||||
sensible initial proportions without limiting direct user resizing or
|
||||
right-side resize compensation. A declared `maxWidth` still wins as the
|
||||
manual limit. Cover may exceed preferred ceilings after other automatic
|
||||
sizing targets are exhausted, preserving the no-blank-filler contract.
|
||||
- Do not give expandable text columns arbitrary `maxWidth` values merely to
|
||||
control their initial appearance. At that limit, a resize handle can shrink
|
||||
but cannot grow, regardless of whether the next column is resizable. Prefer
|
||||
weighted `minmax(..., Nfr)` declarations with `preferredMaxWidth` when needed;
|
||||
ordinary cover-mode growth may
|
||||
create horizontal overflow without changing fixed neighbors. Campaign's
|
||||
Recipient(s), Delivery and configurable recipient-field columns follow this
|
||||
contract. The existing signature format is unchanged for other columns;
|
||||
introducing a preferred ceiling invalidates only that grid's previous width
|
||||
contract, while sort/filter preferences remain intact.
|
||||
- `columnType: "actions"` marks a custom action/control column. Canonical
|
||||
`TableActionGroup` content is recognized automatically, even in existing
|
||||
column declarations. Use `sticky: "end"` for the normal row-action surface.
|
||||
@@ -71,6 +103,14 @@ den laufenden Ziehvorgang. Am fokussierten Trenner ändern Links/Rechts die Brei
|
||||
um 10 px, mit Umschalt um 40 px. Eingabe oder Doppelklick setzt die persönliche
|
||||
Breite dieser Spalte zurück. Schmale Aktionenspalten umbrechen ihre Schaltflächen;
|
||||
breite Tabellen bleiben horizontal scrollbar.
|
||||
Eine feste Maximalbreite stoppt das Vergrößern unabhängig von Nachbarspalten.
|
||||
`preferredMaxWidth` begrenzt dagegen nur die automatische Anfangsaufteilung,
|
||||
nicht persönliche Breiten oder den Ausgleich beim Ziehen. Empfänger,
|
||||
Zustellung und konfigurierte Empfängerfelder verwenden diese Darstellungsgrenze.
|
||||
Bei Bedarf verbreitert sich die Tabelle und bleibt scrollbar; persönliche
|
||||
Breiten ändern keine Kampagnendaten. Am rechten Scrollrand kann eine breite
|
||||
linke Spalte wieder verkleinert werden, weil rechte Textspalten über ihre
|
||||
bevorzugte Anfangsbreite hinaus Platz aufnehmen dürfen.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
# Integrity-preserving performance contracts
|
||||
|
||||
These are implementation guarantees and regression-test boundaries, not a
|
||||
security certification or production load-test result. Feature-specific policies
|
||||
and help remain in the owning modules' English and German documentation topics.
|
||||
|
||||
## Refreshes, edits and table rendering
|
||||
|
||||
The shell runs at most one module-load refresh per authority generation. Multiple
|
||||
invalidations coalesce into one trailing refresh; stale results and errors cannot
|
||||
replace a newer generation. Focus refreshes are throttled to five seconds and
|
||||
focus/visibility refreshes on hidden pages are suppressed; explicit module
|
||||
invalidations still trigger a read. Authentication/tenant reset disposes the
|
||||
previous controller. Authentication and authorization checks are not cached away.
|
||||
|
||||
Editable modules must reconcile a save against the submitted draft and accepted
|
||||
server revision: edits made while a request is pending remain dirty. Completion
|
||||
must be fenced by security-relevant authority and selection, not merely an auth
|
||||
object's reference identity. A harmless profile refresh must not discard an
|
||||
accepted newly created ID and invite a duplicate create. An old account's mutation
|
||||
continuation must not reload its catalogue into the current account's page.
|
||||
|
||||
DataGrid precomputes first-occurrence row indices once for client sorting and
|
||||
filtering. Duplicate object references, primitive values, `NaN` and sparse arrays
|
||||
retain `Array.indexOf` behavior. Comparator order, visible pagination, server-side
|
||||
pagination, sizing and resize rules are unchanged. Do not replace this with a
|
||||
last-occurrence map or change ordering as a side effect of an optimization.
|
||||
|
||||
## Conditional responses
|
||||
|
||||
The shared JSON GET middleware performs route handling, including authorization,
|
||||
before considering `If-None-Match`. It buffers only responses up to 1 MiB for a
|
||||
body-derived ETag. Known larger responses bypass buffering; an unknown-length
|
||||
stream crossing that limit replays its exact prefix and streams the remainder.
|
||||
No data is truncated and no large joined copy is created. The crossing chunk is
|
||||
already producer-owned: this is not a process-wide or route-output memory limit.
|
||||
Empty chunks do not accumulate. Large responses may no longer receive a
|
||||
middleware-generated ETag/304; explicit route ETags remain intact. Small-response
|
||||
cache semantics and credential/language/context `Vary` fields are retained.
|
||||
|
||||
## Shared helpers and concurrency
|
||||
|
||||
Central helpers replace exact live-code duplicates only. Actor precedence,
|
||||
whitespace handling and service-account differences remain explicit owner choices;
|
||||
historical migration code is not redirected to mutable runtime helpers. Connector
|
||||
search ACL token projection keeps its first-seen ordering and existing 500-token
|
||||
cap, stopping work once that cap is reached. Provider schemas are inferred in one
|
||||
pass without storing a second list of every column value.
|
||||
|
||||
The keyed-list three-way merge retains insertion anchors when unrelated fields
|
||||
change. Concurrent additions use deterministic ordering; contradictory anchors
|
||||
produce a collection-order conflict instead of silently relocating an item.
|
||||
No existing endpoint is newly opted into merge behavior by this change.
|
||||
|
||||
SQL JSON authorization predicates support the explicitly tested SQLite and
|
||||
PostgreSQL dialects, retain exact string membership and reject unsupported
|
||||
dialects. Apply tenant and authorization predicates before counting/pagination;
|
||||
never page a broader result first and filter away unauthorized records afterward.
|
||||
SQLite execution and PostgreSQL SQL compilation are not substitutes for a
|
||||
deployment's PostgreSQL concurrency and representative-data load tests.
|
||||
|
||||
## Migration connection URLs
|
||||
|
||||
Alembic preserves the configured database URL exactly in online and offline
|
||||
migration modes, including percent-encoded credentials and PostgreSQL Unix-socket
|
||||
paths. Escaping applies only at its ConfigParser boundary; operators must not
|
||||
double-escape `%` in `DATABASE_URL` or alter working connection credentials to
|
||||
work around interpolation errors. This does not change the target database,
|
||||
authentication, TLS policy, or migration contents.
|
||||
|
||||
## Deutsch: Integrität vor Geschwindigkeit
|
||||
|
||||
Der zentrale Modul-Refresh bündelt gleichzeitige Auslöser und verwirft veraltete
|
||||
Ergebnisse einschließlich Fehlermeldungen. Ein Wechsel von Anmeldung oder Mandant
|
||||
beendet die bisherige Generation. Fokusaktualisierungen sind auf einen Auslöser
|
||||
je fünf Sekunden begrenzt. Berechtigungsprüfungen bleiben erhalten.
|
||||
|
||||
Speicherantworten dürfen zwischenzeitliche Bearbeitungen nicht überschreiben.
|
||||
Ein unveränderter Berechtigungskontext mit einem neuen Profilobjekt darf eine
|
||||
bereits bestätigte neue ID oder Revision nicht verwerfen. Umgekehrt dürfen alte
|
||||
Anfragen nach einem Kontowechsel keine Daten in den neuen Kontext übernehmen.
|
||||
Die DataGrid-Optimierung erhält Reihenfolge, Filter-, Seiten- und Größenverhalten.
|
||||
|
||||
Die ETag-Middleware puffert höchstens 1 MiB Nutzdaten zuzüglich eines bereits vom
|
||||
Erzeuger gelieferten Grenz-Chunks. Größere Antworten werden vollständig weitergereicht,
|
||||
nicht abgeschnitten; automatisch erzeugte ETags können dabei entfallen.
|
||||
Autorisierung läuft auch bei bedingten Anfragen. Das ist keine allgemeine
|
||||
Speicherbegrenzung für Routen oder Prozesse.
|
||||
|
||||
Gemeinsame Helfer erhalten die bisherigen fachlichen Unterschiede. Listen-Merges
|
||||
bewahren Einfügepositionen oder melden widersprüchliche Reihenfolgen explizit als
|
||||
Konflikt. Datenbankseitige Autorisierung erfolgt vor Zählung und Seitenauswahl.
|
||||
Regressionstests belegen diese Verträge; reale Provider-, PostgreSQL- und Lasttests
|
||||
in einer repräsentativen Umgebung bleiben Teil der Betriebsfreigabe.
|
||||
|
||||
Alembic übernimmt die konfigurierte Datenbank-URL in Online- und Offline-Läufen
|
||||
unverändert, einschließlich prozentkodierter Zugangsdaten und PostgreSQL-
|
||||
Unix-Socket-Pfade. Die Maskierung erfolgt ausschließlich an der ConfigParser-
|
||||
Grenze; `%` in `DATABASE_URL` nicht doppelt maskieren und funktionierende
|
||||
Zugangsdaten nicht als Umgehung ändern. Zieldatenbank, Anmeldung, TLS-Vorgaben und
|
||||
Migrationsinhalte bleiben unverändert.
|
||||
@@ -137,3 +137,15 @@ Browser acceptance is part of the focused workspace gate and can be run alone:
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
npm run test:conformance
|
||||
```
|
||||
|
||||
For shared component regressions, `npm run test:components -- page-layout
|
||||
documentation-help layout-primitives` compiles the component test configuration
|
||||
once and executes the selected suites. With no suite names it runs every shared
|
||||
component suite. Existing single-suite aliases such as `npm run test:page-layout`
|
||||
remain supported. Every invocation owns an ignored, uniquely named temporary
|
||||
output directory and removes it on completion, failure, or handled interruption;
|
||||
parallel invocations cannot delete each other's compiled tests. A forcibly killed
|
||||
process may leave its own ignored directory behind. The launcher itself is
|
||||
checked with `npm run test:component-runner` without compiling the application or
|
||||
starting a server. Browser conformance remains a separate verification layer;
|
||||
passing component tests does not constitute a complete module review.
|
||||
|
||||
@@ -7,6 +7,38 @@ emits a draft only after a real edit. A drag onto the same position, keyboard
|
||||
pickup/drop without movement, and opening inherited settings do not save or
|
||||
create an override.
|
||||
|
||||
## Standard layout / Standardanordnung
|
||||
|
||||
No configuration is required to enable sections. Installed modules contribute
|
||||
the standard product-area membership, labels, and order: Work; Services and
|
||||
cases; Records and documents; Communication; Meetings and decisions; Data and
|
||||
assurance; People and responsibility. The dashboard stays first; unclassified
|
||||
destinations remain available under More tools. Only nonempty, authorized and
|
||||
View-visible groups appear. Composed product entries keep their placement
|
||||
owner's directly declared area. If none exists, the navigation aliases of
|
||||
other authorized owners preserve area membership. These are live defaults,
|
||||
not a saved copy that needs replacing whenever optional modules change.
|
||||
|
||||
The editor explains whether the current level is inherited, custom grouped, or
|
||||
explicitly flat. To restore defaults, choose **Use inherited layout** and Save
|
||||
on the owning page: personal Settings inherits the tenant, tenant settings
|
||||
inherit the system, and system settings inherit the standard product areas.
|
||||
An explicitly flat personal, tenant, system, or View configuration is never
|
||||
silently replaced with standard groups. A View may also explicitly select flat
|
||||
navigation. Reset the responsible override rather than changing permissions.
|
||||
|
||||
Abschnitte sind ohne zusätzliche Konfiguration aktiv. Die Standardanordnung
|
||||
gliedert verfügbare Module in Arbeit; Leistungen und Vorgänge; Akten und
|
||||
Dokumente; Kommunikation; Termine und Entscheidungen; Daten und
|
||||
Qualitätssicherung; Personen und Verantwortung. Das Dashboard steht davor,
|
||||
nicht zugeordnete Ziele bleiben unter Weitere Werkzeuge erreichbar. Leere oder
|
||||
nicht zugängliche Gruppen erscheinen nicht. Der gemeinsame Editor zeigt an,
|
||||
ob eine geerbte, eigene gruppierte oder ausdrücklich ungegliederte Anordnung
|
||||
vorliegt. **Geerbte Anordnung verwenden** und anschließendes Speichern entfernt
|
||||
nur die Anpassung dieser Ebene: persönlich → Mandant → System →
|
||||
Standardanordnung. Bewusst gespeicherte ungegliederte Anordnungen bleiben
|
||||
erhalten. Diese Darstellung erteilt keine Berechtigungen und ändert keine Daten.
|
||||
|
||||
## Stored document and precedence
|
||||
|
||||
The version-1 navigation document retains `order`, `hidden` and `locked` and
|
||||
|
||||
@@ -4,6 +4,12 @@ This document defines the binding composition grammar for headed GovOPlaN
|
||||
pages. Core owns the reusable anatomy; each module owns its domain actions,
|
||||
wording, authorization, consequences, and data state.
|
||||
|
||||
The cross-product [UI design principles](UI_DESIGN_PRINCIPLES.md) define the
|
||||
display-first editing model, heading-adjacent help, and module-review process.
|
||||
Normal overview/detail surfaces show readable facts; edit coherent settings in
|
||||
scoped dialogs. Use an explicit editor mode for a justified broad-editing task,
|
||||
not merely because a page also offers filters or contains an edit dialog (UI-02).
|
||||
|
||||
## Required Page Frame
|
||||
|
||||
- Use `PageLayout` for every headed standalone, workspace, or embedded page.
|
||||
@@ -69,11 +75,19 @@ page or pane action bar.
|
||||
|
||||
| Page kind | Leading group | Trailing group |
|
||||
| --- | --- | --- |
|
||||
| Overview | Context | Help, Reload when refreshable, then ordinary primary actions |
|
||||
| Collection | Collection context such as export | Help, Reload when refreshable, then Create at the far right |
|
||||
| Detail | Object context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
| Editor | Context | Dirty state, Help, Reload if distinctly safe, ordinary primary actions, separated destructive actions, Discard, then Save at the far right |
|
||||
| Workspace | Task context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
| Overview | Context | Reload when refreshable, then ordinary primary actions |
|
||||
| Collection | Collection context such as export | Reload when refreshable, then Create at the far right |
|
||||
| Detail | Object context | Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
| Editor | Context | Dirty state, Reload if distinctly safe, ordinary primary actions, separated destructive actions, Discard, then Save at the far right |
|
||||
| Workspace | Task context | Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
|
||||
Documentation is associated with text, not an action-group slot (UI-01). Put
|
||||
`DocumentationHelpLink` in the heading component's `titleHelp`, use `TextWithHelp`
|
||||
for existing contextual words, and retain `FormField`/`FieldLabel` documentation
|
||||
beside field labels. Full-canvas `WorkspaceActionBar` surfaces can supply `title`
|
||||
and `titleHelp` together on the leading side. Do not duplicate a heading or use
|
||||
`helpAction` for a detached documentation icon. See the shared principle for
|
||||
card/dialog accessibility and examples.
|
||||
|
||||
Reload and Create belong to the same right-aligned group, in that order. A
|
||||
collection-wide toolbar stays above its workspace, not inside the left tree or
|
||||
@@ -82,12 +96,40 @@ not remove it. Permission-blocked creation remains visible with an explanation.
|
||||
On narrow screens the trailing group wraps while retaining right alignment and
|
||||
the same DOM/keyboard order.
|
||||
|
||||
An editor's **Cancel/Close** action is not the same as **Reset/Discard changes**.
|
||||
When it exits an editing mode, set `discardAction.behavior` to `"exit"`: it stays
|
||||
enabled for a clean draft, while Save remains disabled until there are changes.
|
||||
The default `"reset"` behavior still requires changes. Both stay blocked during
|
||||
an in-flight save; the owning page must use the shared discard confirmation before
|
||||
exiting a dirty draft. Never require a meaningless edit just to leave configuration.
|
||||
|
||||
Deutsch: Abbrechen oder Schließen beendet den Bearbeitungsmodus auch ohne
|
||||
Änderungen (`discardAction.behavior="exit"`). Zurücksetzen setzt dagegen einen
|
||||
geänderten Entwurf voraus. Beim Abbrechen eines geänderten Entwurfs vor dem
|
||||
Verwerfen nachfragen; eine laufende Speicherung bleibt geschützt.
|
||||
|
||||
Use `Card bodyLayout="table"` for table surfaces, including tables wrapped by
|
||||
`LoadingFrame`. This removes body padding explicitly, without relying on the
|
||||
number of children or negative margins. Place any meaningful explanation or
|
||||
warning in a padded `ContentSection`; do not add a redundant tagline to every
|
||||
table. Use `ContentGrid` for sibling cards so spacing does not depend on fragments.
|
||||
|
||||
The shared stylesheet also recognizes existing table-only card bodies, either
|
||||
directly or through `LoadingFrame`; loading must not add an inset, expand the
|
||||
overlay outside the card, or change the table's measured width. Keep the table
|
||||
at 100% of its container. Do not reintroduce module-local negative margins,
|
||||
expanded `calc()` widths, or padding overrides to make a table fit. Mixed
|
||||
content cards retain their normal padding unless they explicitly declare the
|
||||
table layout. The browser conformance matrix checks all four card edges,
|
||||
loading/collapse transitions, scrolling, and row actions at desktop and mobile
|
||||
widths.
|
||||
|
||||
Deutsch: Tabellen belegen den Karteninhalt ohne zusätzlichen Innenabstand,
|
||||
auch während des Ladens. Neue Tabellenkarten verwenden ausdrücklich
|
||||
`bodyLayout="table"`; ergänzende Hinweise erhalten ihren eigenen Innenabstand.
|
||||
Negative Ränder oder modulbezogene Breitenkorrekturen sind nicht nötig.
|
||||
Karten mit gemischtem Text- oder Formularinhalt behalten ihre normalen Abstände.
|
||||
|
||||
Use `MultiSelectFilter` for standalone list facets. It and DataGrid share the
|
||||
same checkbox body and Select all / Deselect all behavior. `null` means no
|
||||
restriction, `[]` means no matches, and multiple values mean OR within a facet.
|
||||
@@ -136,7 +178,8 @@ to fill the slot.
|
||||
Editor bars always keep Discard and Save visible. Their required `state`
|
||||
projection is one of `clean`, `dirty`, `invalid`, `saving`, `save-failed`, or
|
||||
`conflict`, and the central component announces it through a live status label.
|
||||
Clean and saving states disable both persistence actions; invalid disables Save
|
||||
Clean states disable Save and reset-style Discard, but keep exit-style Cancel
|
||||
available; saving disables both persistence actions. Invalid disables Save
|
||||
while retaining Discard. Failed saves and conflicts keep the draft recoverable
|
||||
and allow an authorized retry after the module has shown the owning error or
|
||||
conflict evidence. A module may add a more specific validation, policy, or
|
||||
|
||||
@@ -13,3 +13,5 @@ tools/checks/security-audit/run.sh --mode full --scope govoplan
|
||||
Canonical documentation:
|
||||
|
||||
- `/mnt/DATA/git/govoplan/docs/operations/SECURITY_AUDIT.md`
|
||||
|
||||
Implementation contract: [disposable resource-bounded operations](BOUNDED_PROCESS_CONTRACT.md).
|
||||
|
||||
@@ -19,3 +19,72 @@ Connector health and preview diagnostics must contain no credentials, endpoint
|
||||
userinfo, row values, or unbounded remote error bodies. A Datasource origin
|
||||
preserves this contract so registration and staging do not erase source mode,
|
||||
health, pushdown, or preview-limit evidence.
|
||||
|
||||
## Durable CSV imports and original evidence
|
||||
|
||||
`TabularCsvSource` optionally accompanies a durable `TabularSnapshotInput` or
|
||||
`DatasourceStageInput`. It carries the exact submitted Unicode text, delimiter,
|
||||
explicit value mode and parser profile. It is not part of ordinary catalogue,
|
||||
preview or stage DTOs. Transient inspection does not retain original content.
|
||||
|
||||
The `text` mode preserves cell strings, including whitespace, leading zeroes,
|
||||
decimal spelling, boolean-looking text and explicit empty cells. It rejects
|
||||
malformed quoting and rows with missing or extra cells. Header normalization is
|
||||
unchanged. The API default remains `legacy_typed` for existing integrations;
|
||||
interactive CSV imports offer text mode by default and an explicit legacy choice.
|
||||
JSON and existing stored snapshots are not reinterpreted. Core and Datasources
|
||||
retain distinct versioned legacy parser profiles where their historical coercion
|
||||
rules differ. Shared schema inference preserves first-seen column order, missing
|
||||
value nullability and the owning provider's type naming.
|
||||
|
||||
Owners verify that the source reparses to exactly the stored projection, including
|
||||
scalar types: `true`, `1` and `1.0` are not equivalent evidence. Raw input and row
|
||||
projections each have a 5,000,000-byte limit; row parsing is capped at 10,000 rows.
|
||||
The original text has its own UTF-8 SHA-256 and byte count, separate from the
|
||||
existing row fingerprint. Only a small allowlisted source summary enters metadata.
|
||||
Checksums detect drift; they are not digital signatures or protection against an
|
||||
attacker who can rewrite the database and all its evidence.
|
||||
|
||||
Original exports are explicit owner APIs, tenant scoped, integrity checked and
|
||||
`no-store`. Datasources additionally requires administrator scope, audits the
|
||||
export, and denies the whole original when current or historical governance
|
||||
restricts any row or field. Freezing verifies the prior summary before copying
|
||||
source evidence and retains prior policy restrictions, including referenced
|
||||
policy evaluations. At most 32 distinct governance snapshots may accompany one
|
||||
original; further incompatible history fails explicitly. Payload disposal also
|
||||
disposes retained original content. Connectors applies its own current read,
|
||||
tenant and lifecycle checks. See the owning module's documentation for endpoints.
|
||||
|
||||
Original UTF-8 text is not proof of pre-decoding file bytes, and does not undo
|
||||
CSV spreadsheet formula semantics. Exported content is deliberately unmodified;
|
||||
operators must treat it as untrusted input when opening it in a spreadsheet.
|
||||
New nullable columns require the owning modules' additive migrations. Historical
|
||||
rows remain unchanged and report original content unavailable, not reconstructed.
|
||||
Back up retained originals before any schema downgrade that removes those columns.
|
||||
|
||||
## Deutsch: CSV-Datentreue
|
||||
|
||||
Dauerhafte CSV-Importe können den unveränderten übermittelten Unicode-Text mit
|
||||
Trennzeichen, Parserprofil und explizitem Wertemodus aufbewahren. Der Textmodus
|
||||
erhält Zellwerte einschließlich Leerzeichen, führender Nullen und Dezimalschreibweise.
|
||||
Fehlerhafte Zeilen werden abgewiesen. Die API bleibt aus Kompatibilitätsgründen bei
|
||||
der bisherigen Typumwandlung als Standard; im Importdialog ist Text voreingestellt.
|
||||
Bestehende Daten und JSON-Importe werden nicht neu interpretiert.
|
||||
|
||||
Original und Zeilenprojektion werden getrennt begrenzt und geprüft; boolesche
|
||||
Werte, Ganzzahlen und Gleitkommazahlen sind keine austauschbaren Belege. Der
|
||||
Originaltext erscheint weder im Katalog noch in Vorschauantworten. Die expliziten
|
||||
Export-APIs prüfen Mandant, Berechtigungen, Lebenszyklus und gespeicherten Hash.
|
||||
Datasources verlangt zusätzlich Administrationsrechte, protokolliert Exporte und
|
||||
berücksichtigt aktuelle sowie historische Zeilen-, Feld- und Zugriffsrichtlinien.
|
||||
Eingeschränkte Originale werden vollständig gesperrt, nicht teilweise freigegeben.
|
||||
Eingefrorene Kopien übernehmen diese Einschränkungen; nach 32 unterschiedlichen
|
||||
Richtlinienständen wird eine weitere Kopie mit neuer Richtlinie explizit abgewiesen.
|
||||
Die Aufbewahrungsbereinigung entfernt auch das gespeicherte Original.
|
||||
|
||||
Die Grenzen betragen jeweils 5.000.000 UTF-8-Bytes für Original und Projektion
|
||||
sowie 10.000 Zeilen. Prüfsummen sind keine Signaturen. Ein CSV-Original bleibt beim
|
||||
Export unverändert und kann Tabellenkalkulationsformeln enthalten. Frühere
|
||||
Dateikodierungen lassen sich daraus nicht rekonstruieren. Additive Migrationen
|
||||
ändern keine historischen Zeilen; fehlende Originale werden nicht erfunden.
|
||||
Vor einem Schema-Downgrade sind aufbewahrte Originale zu sichern.
|
||||
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
# Product UI design principles
|
||||
|
||||
These rules govern every GovOPlaN module, including administration, tenant and
|
||||
user settings, public forms, widgets, dialogs and shared shell surfaces. Core
|
||||
owns the reusable components; modules own their meaning and authorized behavior.
|
||||
They complement the [page composition contract](PAGE_LAYOUT_USAGE_GUIDELINES.md).
|
||||
|
||||
Initial rule revision: **UI-2026-09-08**. Record this identifier in review evidence;
|
||||
subsequent rule changes need a dated change reference and a UI-09 propagation check.
|
||||
|
||||
## UI-01 — Help belongs to visible text
|
||||
|
||||
Place a small documentation book immediately after the heading or label whose
|
||||
meaning it explains. For example, the Dashboard book belongs to **Dashboard**,
|
||||
not the Reload, Edit or Save group. The same applies to page, pane, card and
|
||||
dialog titles, field labels, widget summaries and contextual explanations.
|
||||
Do not put an isolated documentation icon in an action bar or in an otherwise
|
||||
empty row. A navigation icon is not a contextual documentation link.
|
||||
|
||||
Use `titleHelp={<DocumentationHelpLink reference={...} />}` on `PageLayout`,
|
||||
`PageHeader`, `PageTitle`, `AdminPageLayout`, `Card` or `Dialog`. Full-canvas
|
||||
workspaces use the visible `title` and `titleHelp` of `WorkspaceActionBar` (or
|
||||
`PageActionBar`), separate from its action groups. Use `TextWithHelp` around
|
||||
existing text for smaller contexts; fields use `FormField`/`FieldLabel` and
|
||||
their documentation reference. Do not duplicate the heading to add help.
|
||||
|
||||
Dashboard widgets provide their owning `documentation` reference in
|
||||
`DashboardWidgetContribution`; Dashboard renders it beside the existing widget
|
||||
card title. Do not add a second widget heading or a footer-only documentation icon.
|
||||
|
||||
Keep the icon outside the heading's accessible name and outside other buttons
|
||||
or links. It needs an accessible purpose, keyboard focus and a usable hit area.
|
||||
Long German headings must wrap without colliding with actions. Collapsing a
|
||||
card or configuring a dashboard must not change which title the book explains.
|
||||
The existing optional Docs-module/hosted-documentation fallback remains intact;
|
||||
placement does not change permissions or require the Docs module.
|
||||
|
||||
## UI-02 — Display first; edit deliberately
|
||||
|
||||
Ordinary overview and detail pages show compact, readable data. Group related
|
||||
facts with shared description, summary and card components. Offer a clear Edit
|
||||
action that opens a scoped dialog for a coherent group of settings. Avoid
|
||||
presenting every possible input, select and save control simultaneously.
|
||||
|
||||
The Campaign dashboard is a priority application: show the effective settings
|
||||
and their state compactly, then edit related settings in focused dialogs. This
|
||||
is review work to implement, not a claim that all current screens already comply.
|
||||
|
||||
An explicit editing mode is a justified exception for genuinely broad editing,
|
||||
large tables, graphical designers or other tasks poorly served by a modal.
|
||||
Record why the exception helps the user. Keep reading and editing distinguishable;
|
||||
enter editing deliberately and preserve shared Save/Cancel, validation, conflict,
|
||||
permission and unsaved-draft behavior. Filters, search and selection do not need
|
||||
an edit dialog: they change the view rather than the underlying data.
|
||||
|
||||
Cancel/Close must work even without changes. Never save a draft merely because
|
||||
a dialog opens, selection changes or navigation happens. Commit only the intended
|
||||
fields; do not let unrelated incomplete settings block an independent edit.
|
||||
|
||||
## UI-03 — Predictable actions and consequences
|
||||
|
||||
Follow the shared semantic page/pane action contract: context on the left;
|
||||
Reload on refreshable surfaces followed by Create on the right. Editable drafts
|
||||
have the standard state, Cancel/Discard and Save controls. Keep positions stable
|
||||
across selection and loading changes, and explain unavailable applicable actions.
|
||||
Separate destructive actions visually and semantically. Reload is a read, not
|
||||
an implicit synchronization, import, send or other mutation.
|
||||
|
||||
## UI-04 — Shared geometry and readable collections
|
||||
|
||||
Use central page, card, table, form-grid and dialog primitives. Table cards use
|
||||
`bodyLayout="table"`, spanning the card body without negative margins or expanded
|
||||
`calc()` widths. Keep row actions reachable and deliberate column resizing
|
||||
predictable. Avoid redundant taglines, arbitrary gaps, fixed dialog widths and
|
||||
horizontal scrolling where content can wrap. Use actual pagination and shared
|
||||
list filters; every result and every validation detail must remain reachable.
|
||||
|
||||
## UI-05 — Stable feedback and loading
|
||||
|
||||
Preserve useful loaded content on refresh failure; distinguish initial loading,
|
||||
empty, unavailable, stale, blocked, failed and successful states. Use the shared
|
||||
scoped loading overlay. Long operations show truthful processed/total and outcome
|
||||
counts when known, not simulated progress or whole-page background reloads.
|
||||
Prevent duplicate submissions without trapping the user in a clean editing mode.
|
||||
|
||||
## UI-06 — Predictable navigation and selection
|
||||
|
||||
Use shared breadcrumbs, side rail and tree contracts. Tree disclosure controls
|
||||
expand/collapse; labels select. Track the clicked occurrence when one topic or
|
||||
record appears in several branches. Preserve context, filters and selection
|
||||
where appropriate, without carrying data across authorization or tenant scopes.
|
||||
|
||||
## UI-07 — Accessibility and German parity
|
||||
|
||||
Check keyboard order, visible focus, accessible names, dialog focus restoration,
|
||||
contrast, zoom and narrow layouts. Status must not rely on color alone. Use
|
||||
plain, task-oriented wording and complete German UI and module-owned help for
|
||||
the changed workflow. Long labels must not hide actions or force needless scroll.
|
||||
|
||||
## UI-08 — Integrity and permissions are not visual shortcuts
|
||||
|
||||
Preserve authorization, versioning, audit evidence, immutable historical data and
|
||||
safe retries. Do not remove a guard to make an action look available. Keep drafts
|
||||
recoverable after failures and conflicts. Loading, cancellation and dialog
|
||||
transitions must not silently write, discard or overwrite data. Never verify UI
|
||||
changes by sending real mail or changing production records without authorization.
|
||||
|
||||
## UI-09 — Review incrementally and propagate lessons
|
||||
|
||||
The [Meta UI-review epic](https://git.add-ideas.de/GovOPlaN/govoplan/issues/56)
|
||||
links one review ledger per module. Each starts pending, including modules
|
||||
touched by a focused shared-component pass. An implemented help-placement rule
|
||||
does not constitute a full module review.
|
||||
|
||||
Inventory the module's surfaces, inspect them with realistic data and permissions,
|
||||
record findings and decisions, implement bounded fixes, then attach automated
|
||||
and manual evidence. Distinguish **to do**, **implemented**, **verified**, and
|
||||
**accepted exception**. Record the applicable principle IDs and actual screen
|
||||
coverage; do not mark a module complete from a code search alone.
|
||||
|
||||
When a review reveals a reusable lesson, update this document and the central
|
||||
component/check first where possible. Record the rule change in Meta and create
|
||||
a follow-up or reopen affected already-reviewed modules. The review ledger must
|
||||
show which rule revision each module has actually verified. Prefer finishing
|
||||
the active review slice before expanding functionality, while recording unrelated
|
||||
bugs and decision-dependent work without losing them.
|
||||
|
||||
## Kurzfassung auf Deutsch
|
||||
|
||||
- **UI-01:** Das Dokumentationsbuch steht direkt rechts neben der zugehörigen
|
||||
Überschrift oder Beschriftung, nicht zwischen Aktionsschaltflächen.
|
||||
- **UI-02:** Daten zunächst kompakt anzeigen; zusammengehörige Einstellungen
|
||||
gezielt in einem Dialog bearbeiten. Ein ausdrücklich aktivierter
|
||||
Bearbeitungsmodus, etwa für große Tabellen, ist eine begründete Ausnahme.
|
||||
Abbrechen funktioniert auch ohne Änderungen; Änderungen werden bewusst gespeichert.
|
||||
- **UI-03–UI-08:** Einheitliche Aktionen und Abstände, erreichbare Tabelleninhalte,
|
||||
verständlicher Fortschritt, vorhersehbare Navigation, Barrierefreiheit und
|
||||
deutsche Texte dürfen Berechtigungen oder Datenintegrität nicht schwächen.
|
||||
- **UI-09:** Alle Module werden einzeln geprüft. Neue Gestaltungsregeln werden
|
||||
auch in bereits geprüften Modulen nachgezogen und mit Nachweisen dokumentiert.
|
||||
|
||||
## Automated coverage and its limits
|
||||
|
||||
`tools/checks/check-heading-help.mjs` in Meta checks JSX documentation-link
|
||||
placement throughout the workspace, including aliases and simple local variables.
|
||||
Core component and browser tests check heading association, accessible names,
|
||||
focus, collapse/loading transitions and narrow/wide layouts. Existing layout,
|
||||
action, dialog and DataGrid checks continue to apply. These tests protect shared
|
||||
contracts; they do not certify every module's usability or replace the individual
|
||||
screen reviews and their documented exceptions.
|
||||
@@ -2768,6 +2768,498 @@
|
||||
"release": "0.1.45",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6e2f91ab4c70"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revision": "7b9d2f4a6c8e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tasks",
|
||||
"revision": "7c4d9a2e1f30"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-records",
|
||||
"revision": "8a6c4e2f1b3d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revision": "8b9c0d1e2f3a"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tickets",
|
||||
"revision": "8d1f4b7a2c5e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-quick-access",
|
||||
"revision": "9a4e6c2d8f10"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-helpdesk",
|
||||
"revision": "9e2a5c8f1b4d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revision": "9e6b3f8a2c7d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a2b3c4d5e701"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revision": "a3d7f1c5e9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revision": "a3f7c9d2e1b4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revision": "a61e4d9c72b8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-wiki",
|
||||
"revision": "a7c2e9f4b1d6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revision": "a7c4e2f9b1d6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revision": "a8b1c2d3e4f5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revision": "a91c4e72b5d8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revision": "a9c4e7b2d5f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revision": "b2c3d4e5f607"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tenancy",
|
||||
"revision": "b3d8e1f4a6c2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "b5d6e7f8091a"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revision": "b9c0d1e2f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revision": "b9c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revision": "b9e2f5a8c3d6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revision": "c0d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "c2d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revision": "c3f5a7b9d1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revision": "c4a1e8f2d6b9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revision": "c58a2d7e9f10"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revision": "c6f2a9d4e7b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revision": "c8d5e2f6a9b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revision": "d1e4f5a6b7c8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "d24e5f607182"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revision": "d2a4c6e8f0b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-docs",
|
||||
"revision": "d3e7a1c5f9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revision": "d6a8b0c2e4f6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "d6e8f9a0b1c2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "d7a4c1e8f205"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revision": "d8b4f1a6c9e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revision": "d8b9f0a1c2e3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revision": "e2b8d4a0f6c3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revision": "e5b7c9d1f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-payments",
|
||||
"revision": "e7b9c1d3f5a7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revision": "e7c3a9d1b5f2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revision": "e9a2c5f8b1d4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "f3c7a9d2e6b1"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"e9a2c5f8b1d4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"d6e8f9a0b1c2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revisions": [
|
||||
"a91c4e72b5d8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revisions": [
|
||||
"b9e2f5a8c3d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"d24e5f607182"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"f3c7a9d2e6b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revisions": [
|
||||
"a7c4e2f9b1d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revisions": [
|
||||
"d8b9f0a1c2e3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revisions": [
|
||||
"d2a4c6e8f0b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"c58a2d7e9f10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revisions": [
|
||||
"7b9d2f4a6c8e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revisions": [
|
||||
"a3d7f1c5e9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revisions": [
|
||||
"e2b8d4a0f6c3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revisions": [
|
||||
"d1e4f5a6b7c8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revisions": [
|
||||
"e7c3a9d1b5f2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-docs",
|
||||
"revisions": [
|
||||
"d3e7a1c5f9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revisions": [
|
||||
"e5b7c9d1f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a2b3c4d5e701"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms",
|
||||
"revisions": [
|
||||
"e1f2a3b4c5d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revisions": [
|
||||
"d6a8b0c2e4f6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-helpdesk",
|
||||
"revisions": [
|
||||
"9e2a5c8f1b4d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revisions": [
|
||||
"c3f5a7b9d1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"c2d3e4f5a6b7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"b5d6e7f8091a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revisions": [
|
||||
"a8b1c2d3e4f5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6e2f91ab4c70"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"a61e4d9c72b8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revisions": [
|
||||
"c0d3e4f5a6b7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-payments",
|
||||
"revisions": [
|
||||
"e7b9c1d3f5a7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revisions": [
|
||||
"a9c4e7b2d5f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revisions": [
|
||||
"d8b4f1a6c9e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revisions": [
|
||||
"c4a1e8f2d6b9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-quick-access",
|
||||
"revisions": [
|
||||
"9a4e6c2d8f10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-records",
|
||||
"revisions": [
|
||||
"8a6c4e2f1b3d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revisions": [
|
||||
"c8d5e2f6a9b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revisions": [
|
||||
"b9c0d1e2f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"d7a4c1e8f205"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revisions": [
|
||||
"b2c3d4e5f607"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revisions": [
|
||||
"b9c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tasks",
|
||||
"revisions": [
|
||||
"7c4d9a2e1f30"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revisions": [
|
||||
"a3f7c9d2e1b4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tenancy",
|
||||
"revisions": [
|
||||
"b3d8e1f4a6c2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tickets",
|
||||
"revisions": [
|
||||
"8d1f4b7a2c5e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revisions": [
|
||||
"c6f2a9d4e7b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revisions": [
|
||||
"8b9c0d1e2f3a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-wiki",
|
||||
"revisions": [
|
||||
"a7c2e9f4b1d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revisions": [
|
||||
"9e6b3f8a2c7d"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-09-08T10:17:38Z",
|
||||
"release": "0.1.46",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.45"
|
||||
version = "0.1.46"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -176,6 +176,8 @@ class UserInfo(BaseModel):
|
||||
tenant_display_name: str | None = None
|
||||
is_tenant_admin: bool = False
|
||||
password_reset_required: bool = False
|
||||
required_auth_action: Literal["change_password"] | None = None
|
||||
local_password: bool = False
|
||||
preferred_language: str | None = None
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
||||
@@ -190,6 +192,8 @@ class AuthSessionUserInfo(BaseModel):
|
||||
tenant_display_name: str | None = None
|
||||
is_tenant_admin: bool = False
|
||||
password_reset_required: bool = False
|
||||
required_auth_action: Literal["change_password"] | None = None
|
||||
local_password: bool = False
|
||||
|
||||
|
||||
class AuthSessionResponse(BaseModel):
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import heapq
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||
@@ -436,10 +437,33 @@ def _merge_list(
|
||||
result_by_id[identity] = merged.value
|
||||
|
||||
order_source = local_order if local_reordered and not current_reordered else current_order
|
||||
merged_order = [identity for identity in order_source if identity in result_by_id]
|
||||
for identity in identities:
|
||||
if identity in result_by_id and identity not in merged_order:
|
||||
merged_order.append(identity)
|
||||
secondary_order = current_order if order_source is local_order else local_order
|
||||
# Keep the chosen side's order and both sides' insertion anchors. Appending
|
||||
# missing IDs would silently relocate an insertion during a disjoint edit.
|
||||
# An incompatible reorder/insertion cycle is an explicit conflict.
|
||||
edges: dict[str, set[str]] = {identity: set() for identity in result_by_id}
|
||||
incoming = dict.fromkeys(result_by_id, 0)
|
||||
for order, insertions_only in ((order_source, False), (secondary_order, True)):
|
||||
selected = [identity for identity in order if identity in result_by_id]
|
||||
for left, right in zip(selected, selected[1:]):
|
||||
if insertions_only and left in base_by_id and right in base_by_id:
|
||||
continue
|
||||
if right not in edges[left]:
|
||||
edges[left].add(right)
|
||||
incoming[right] += 1
|
||||
priority = {identity: index for index, identity in enumerate(identities)}
|
||||
ready = [(priority[identity], identity) for identity, count in incoming.items() if count == 0]
|
||||
heapq.heapify(ready)
|
||||
merged_order: list[str] = []
|
||||
while ready:
|
||||
_, identity = heapq.heappop(ready)
|
||||
merged_order.append(identity)
|
||||
for following in edges[identity]:
|
||||
incoming[following] -= 1
|
||||
if incoming[following] == 0:
|
||||
heapq.heappush(ready, (priority[following], following))
|
||||
if len(merged_order) != len(result_by_id):
|
||||
return _conflict(path, "collection_reorder", base, local, current)
|
||||
return ThreeWayMergeResult(
|
||||
value=[result_by_id[identity] for identity in merged_order],
|
||||
conflicts=conflicts,
|
||||
|
||||
@@ -14,6 +14,7 @@ from govoplan_core.core.tabular_sources import (
|
||||
DEFAULT_PREVIEW_BYTES,
|
||||
DEFAULT_PREVIEW_TIMEOUT_MS,
|
||||
TabularPreviewDiagnostic,
|
||||
TabularCsvSource,
|
||||
TabularPushdown,
|
||||
TabularSourceHealth,
|
||||
TabularSourceMode,
|
||||
@@ -369,6 +370,7 @@ class DatasourceStageInput:
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance | None = None
|
||||
csv_source: TabularCsvSource | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -409,6 +409,25 @@ class DocumentationTopic:
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def localize_documentation_topics(
|
||||
topics: Iterable[DocumentationTopic],
|
||||
*,
|
||||
locale: str,
|
||||
translations: Mapping[str, Mapping[str, str]],
|
||||
) -> tuple[DocumentationTopic, ...]:
|
||||
"""Merge owner-supplied text translations without moving feature content."""
|
||||
localized: list[DocumentationTopic] = []
|
||||
for topic in topics:
|
||||
translated = translations.get(topic.id)
|
||||
if translated is None:
|
||||
localized.append(topic)
|
||||
continue
|
||||
values = {name: dict(value) for name, value in topic.translations.items()}
|
||||
values[locale] = {**values.get(locale, {}), **translated}
|
||||
localized.append(replace(topic, translations=values))
|
||||
return tuple(localized)
|
||||
|
||||
|
||||
def localizable_documentation_metadata_keys(
|
||||
topic: DocumentationTopic,
|
||||
) -> tuple[str, ...]:
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
"""Pure principal attribution mechanics, not authorization or tenant resolution.
|
||||
|
||||
The two existing contracts intentionally differ in precedence and whitespace.
|
||||
Callers retain their own service-account, scope and resource-access decisions.
|
||||
"""
|
||||
|
||||
|
||||
def principal_actor_ids(principal: object) -> tuple[str, ...]:
|
||||
"""Account-first legacy IDs, unique in encounter order; retain nonblank text."""
|
||||
user = getattr(principal, "user", None)
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
str(value)
|
||||
for value in (
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
getattr(user, "id", None),
|
||||
)
|
||||
if str(value or "").strip()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def principal_user_first_actor(principal: object) -> str | None:
|
||||
"""First nonblank user/account/identity/membership ID, with trimmed text."""
|
||||
user = getattr(principal, "user", None)
|
||||
for value in (
|
||||
getattr(user, "id", None),
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
):
|
||||
candidate = str(value or "").strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return None
|
||||
@@ -1,11 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@@ -17,6 +21,77 @@ DEFAULT_PREVIEW_TIMEOUT_MS = 2_000
|
||||
TabularSourceMode = Literal["live", "cached", "file_backed", "static"]
|
||||
TabularHealthStatus = Literal["healthy", "warning", "error", "unknown"]
|
||||
TabularDiagnosticSeverity = Literal["info", "warning", "error"]
|
||||
CsvValueMode = Literal["legacy_typed", "text"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularCsvSource:
|
||||
"""Original upload text, retained only with an explicitly durable import.
|
||||
|
||||
This is never catalogue metadata or transient preview retention. Owners
|
||||
enforce access, size limits, lifecycle and export authorization separately.
|
||||
"""
|
||||
|
||||
text: str
|
||||
delimiter: str = ","
|
||||
value_mode: CsvValueMode = "legacy_typed"
|
||||
parser_profile: str = "core.csv.v1"
|
||||
|
||||
|
||||
def csv_source_payload(source: TabularCsvSource, *, max_bytes: int = 5_000_000) -> dict[str, object]:
|
||||
encoded = _csv_utf8_bytes(source.text)
|
||||
if len(encoded) > max_bytes:
|
||||
raise TabularSourceValidationError(f"Original CSV input is limited to {max_bytes:,} UTF-8 bytes.")
|
||||
if source.value_mode not in {"text", "legacy_typed"} or len(source.delimiter) != 1:
|
||||
raise TabularSourceValidationError("Invalid CSV source parsing options.")
|
||||
return {
|
||||
"text": source.text,
|
||||
"delimiter": source.delimiter,
|
||||
"value_mode": source.value_mode,
|
||||
"parser_profile": source.parser_profile,
|
||||
"sha256": hashlib.sha256(encoded).hexdigest(),
|
||||
"byte_count": len(encoded),
|
||||
}
|
||||
|
||||
|
||||
def csv_source_summary(payload: Mapping[str, object]) -> dict[str, object]:
|
||||
"""Allowlist the small, non-content evidence safe for catalogue DTOs."""
|
||||
result = {key: payload[key] for key in ("delimiter", "value_mode", "parser_profile", "sha256", "byte_count")}
|
||||
if "governance_history" in payload:
|
||||
result["governance_sha256"] = hashlib.sha256(json.dumps(payload["governance_history"], sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")).hexdigest()
|
||||
return result
|
||||
|
||||
|
||||
def verified_csv_source_text(payload: Mapping[str, object], *, expected_summary: Mapping[str, object] | None = None) -> str:
|
||||
text = payload.get("text")
|
||||
if not isinstance(text, str):
|
||||
raise TabularSourceUnavailableError("Original CSV source text is unavailable.")
|
||||
try:
|
||||
encoded = text.encode("utf-8")
|
||||
except UnicodeError as exc:
|
||||
raise TabularSourceUnavailableError("Original CSV source encoding is invalid.") from exc
|
||||
if len(encoded) != payload.get("byte_count") or hashlib.sha256(encoded).hexdigest() != payload.get("sha256"):
|
||||
raise TabularSourceUnavailableError("Original CSV source integrity verification failed.")
|
||||
if expected_summary is not None:
|
||||
try:
|
||||
actual = json.dumps(csv_source_summary(payload), sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
expected = json.dumps(expected_summary, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise TabularSourceUnavailableError("Original CSV source evidence is invalid.") from exc
|
||||
if actual != expected:
|
||||
raise TabularSourceUnavailableError("Original CSV source no longer matches its recorded evidence.")
|
||||
return text
|
||||
|
||||
|
||||
def csv_projection_matches(expected: Sequence[Mapping[str, object]], actual: Sequence[Mapping[str, object]]) -> bool:
|
||||
"""CSV cells are scalar: booleans, integers and floats are not interchangeable."""
|
||||
return len(expected) == len(actual) and all(
|
||||
left.keys() == right.keys() and all(
|
||||
type(value) is type(right[name]) and value == right[name]
|
||||
for name, value in left.items()
|
||||
)
|
||||
for left, right in zip(expected, actual, strict=True)
|
||||
)
|
||||
|
||||
|
||||
class TabularSourceError(ValueError):
|
||||
@@ -39,29 +114,44 @@ class TabularSourceUnavailableError(TabularSourceError):
|
||||
pass
|
||||
|
||||
|
||||
def _csv_utf8_bytes(text: str) -> bytes:
|
||||
try:
|
||||
return text.encode("utf-8")
|
||||
except UnicodeError as exc:
|
||||
raise TabularSourceValidationError("CSV input must be valid Unicode encodable as UTF-8.") from exc
|
||||
|
||||
|
||||
def parse_tabular_csv(
|
||||
csv_text: str,
|
||||
*,
|
||||
delimiter: str = ",",
|
||||
max_rows: int = 10_000,
|
||||
max_bytes: int = 5_000_000,
|
||||
value_mode: CsvValueMode = "legacy_typed",
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
"""Parse a bounded CSV document into JSON-compatible tabular rows."""
|
||||
"""Parse CSV with an explicit lexical-text or backward-compatible typed mode."""
|
||||
|
||||
if len(delimiter) != 1:
|
||||
raise TabularSourceValidationError("CSV delimiter must be one character.")
|
||||
if value_mode not in {"legacy_typed", "text"}:
|
||||
raise TabularSourceValidationError("Unsupported CSV value mode.")
|
||||
if len(_csv_utf8_bytes(csv_text)) > max_bytes:
|
||||
raise TabularSourceValidationError(f"CSV input is limited to {max_bytes:,} UTF-8 bytes.")
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
|
||||
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter, strict=value_mode == "text")
|
||||
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
|
||||
rows: list[dict[str, object]] = []
|
||||
for row in reader:
|
||||
_validate_csv_row_shape(row)
|
||||
if _csv_row_is_empty(row, original_headers):
|
||||
if value_mode == "text" and (None in row or any(row.get(header) is None for header in original_headers)):
|
||||
raise TabularSourceValidationError("CSV text rows must have exactly the number of values defined by the header.")
|
||||
if value_mode == "legacy_typed" and _csv_row_is_empty(row, original_headers):
|
||||
continue
|
||||
if len(rows) >= max_rows:
|
||||
raise TabularSourceValidationError(
|
||||
f"CSV snapshots are limited to {max_rows:,} rows."
|
||||
)
|
||||
rows.append(_csv_row(row, original_headers, normalized_headers))
|
||||
rows.append(_csv_row(row, original_headers, normalized_headers, value_mode=value_mode))
|
||||
return tuple(rows)
|
||||
except csv.Error as exc:
|
||||
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
|
||||
@@ -106,9 +196,11 @@ def _csv_row(
|
||||
row: Mapping[str | None, str | list[str] | None],
|
||||
original_headers: Sequence[str],
|
||||
normalized_headers: Sequence[str],
|
||||
*,
|
||||
value_mode: CsvValueMode = "legacy_typed",
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
normalized: _csv_scalar(value if isinstance(value, str) else None)
|
||||
normalized: (value if value_mode == "text" else _csv_scalar(value if isinstance(value, str) else None))
|
||||
for original, normalized in zip(
|
||||
original_headers,
|
||||
normalized_headers,
|
||||
@@ -128,9 +220,15 @@ def _csv_scalar(value: str | None) -> object:
|
||||
if lowered in {"true", "false"}:
|
||||
return lowered == "true"
|
||||
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
|
||||
return int(text)
|
||||
try:
|
||||
return int(text)
|
||||
except ValueError as exc:
|
||||
raise TabularSourceValidationError("CSV integer exceeds the conversion limit; use text mode to preserve it.") from exc
|
||||
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", text):
|
||||
return float(text)
|
||||
value = float(text)
|
||||
if not math.isfinite(value):
|
||||
raise TabularSourceValidationError("CSV numeric value exceeds the finite number range; use text mode to preserve it.")
|
||||
return value
|
||||
return text
|
||||
|
||||
|
||||
@@ -141,6 +239,48 @@ class TabularColumn:
|
||||
nullable: bool = True
|
||||
|
||||
|
||||
def tabular_type_name(value: object, *, casefold_unknown: bool = False) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if isinstance(value, int):
|
||||
return "integer"
|
||||
if isinstance(value, (float, Decimal)):
|
||||
return "number"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
if isinstance(value, list):
|
||||
return "array"
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
name = type(value).__name__
|
||||
return name.casefold() if casefold_unknown else name.lower()
|
||||
|
||||
|
||||
def infer_tabular_schema(
|
||||
rows: Sequence[Mapping[str, object]],
|
||||
*,
|
||||
type_name: Callable[[object], str] = tabular_type_name,
|
||||
) -> tuple[TabularColumn, ...]:
|
||||
"""Infer first-seen columns in one pass without retaining column values.
|
||||
|
||||
The classifier is explicit so legacy providers can preserve their exact
|
||||
type naming. Missing keys and explicit None both make a column nullable.
|
||||
"""
|
||||
states: dict[str, tuple[str | None, int]] = {}
|
||||
for row in rows:
|
||||
for name, value in row.items():
|
||||
kind, concrete = states.get(name, (None, 0))
|
||||
if value is not None:
|
||||
value_kind = type_name(value)
|
||||
kind = value_kind if kind is None else kind if kind == value_kind else "mixed"
|
||||
concrete += 1
|
||||
states[name] = (kind, concrete)
|
||||
return tuple(
|
||||
TabularColumn(name=name, data_type=kind if kind is not None else "unknown", nullable=concrete != len(rows))
|
||||
for name, (kind, concrete) in states.items()
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TabularPushdown:
|
||||
projections: bool = False
|
||||
@@ -221,6 +361,7 @@ class TabularSnapshotInput:
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
description: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
csv_source: TabularCsvSource | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -296,6 +437,10 @@ __all__ = [
|
||||
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
|
||||
"DEFAULT_PREVIEW_BYTES",
|
||||
"DEFAULT_PREVIEW_TIMEOUT_MS",
|
||||
"CsvValueMode",
|
||||
"TabularCsvSource",
|
||||
"tabular_type_name",
|
||||
"infer_tabular_schema",
|
||||
"TabularColumn",
|
||||
"TabularPreviewDiagnostic",
|
||||
"TabularPushdown",
|
||||
@@ -313,6 +458,10 @@ __all__ = [
|
||||
"TabularSourceUnavailableError",
|
||||
"TabularSourceValidationError",
|
||||
"parse_tabular_csv",
|
||||
"csv_source_payload",
|
||||
"csv_source_summary",
|
||||
"verified_csv_source_text",
|
||||
"csv_projection_matches",
|
||||
"tabular_snapshot_writer",
|
||||
"tabular_source_provider",
|
||||
]
|
||||
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
"""Exact, bound-parameter JSON permission predicates for SQLite/PostgreSQL.
|
||||
|
||||
These primitives only match strings (never coerced numbers/booleans) and
|
||||
objects inside actual arrays. Owners still define tenant, subject, permission,
|
||||
purpose and current-state policy. Unsupported dialects fail at compilation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
from sqlalchemy import Boolean, literal
|
||||
from sqlalchemy.exc import CompileError
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.sql.functions import FunctionElement
|
||||
|
||||
|
||||
class _ArrayString(FunctionElement):
|
||||
type = Boolean()
|
||||
inherit_cache = True
|
||||
|
||||
|
||||
class _ObjectStrings(FunctionElement):
|
||||
type = Boolean()
|
||||
inherit_cache = True
|
||||
|
||||
|
||||
class _ArrayObjectStrings(FunctionElement):
|
||||
type = Boolean()
|
||||
inherit_cache = True
|
||||
|
||||
|
||||
def json_array_contains_string(column, value: str):
|
||||
if type(value) is not str:
|
||||
raise TypeError("JSON string membership requires a string value.")
|
||||
return _ArrayString(column, literal(value))
|
||||
|
||||
|
||||
def _field_arguments(fields: Mapping[str, str]):
|
||||
if not isinstance(fields, Mapping) or not 1 <= len(fields) <= 16:
|
||||
raise ValueError("JSON object matching requires between 1 and 16 string fields.")
|
||||
arguments = []
|
||||
for key, value in sorted(fields.items()):
|
||||
if type(key) is not str or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,127}", key) is None:
|
||||
raise ValueError("JSON object field names must be simple identifiers.")
|
||||
if type(value) is not str:
|
||||
raise TypeError("JSON object matching requires string values.")
|
||||
arguments.extend((literal(key), literal(value)))
|
||||
return arguments
|
||||
|
||||
|
||||
def json_object_matches_strings(column, fields: Mapping[str, str]):
|
||||
return _ObjectStrings(column, *_field_arguments(fields))
|
||||
|
||||
|
||||
def json_array_contains_object_strings(column, fields: Mapping[str, str]):
|
||||
return _ArrayObjectStrings(column, *_field_arguments(fields))
|
||||
|
||||
|
||||
@compiles(_ArrayString)
|
||||
@compiles(_ObjectStrings)
|
||||
@compiles(_ArrayObjectStrings)
|
||||
def _unsupported(element, compiler, **kwargs):
|
||||
raise CompileError("Exact JSON permission predicates support only SQLite and PostgreSQL.")
|
||||
|
||||
|
||||
def _parts(element, compiler, kwargs):
|
||||
return [compiler.process(item, **kwargs) for item in element.clauses]
|
||||
|
||||
|
||||
def _sqlite_array(value):
|
||||
return f"CASE WHEN json_type({value}) = 'array' THEN {value} ELSE '[]' END"
|
||||
|
||||
|
||||
def _postgres_array(value):
|
||||
value = f"CAST({value} AS JSON)"
|
||||
return f"CASE WHEN json_typeof({value}) = 'array' THEN {value} ELSE '[]'::json END"
|
||||
|
||||
|
||||
def _sqlite_fields(value, fields):
|
||||
terms = []
|
||||
for index in range(0, len(fields), 2):
|
||||
key, expected = fields[index:index + 2]
|
||||
path = f"('$.' || {key})"
|
||||
terms.extend((f"json_type({value}, {path}) = 'text'", f"json_extract({value}, {path}) = {expected}"))
|
||||
return " AND ".join(terms)
|
||||
|
||||
|
||||
def _postgres_fields(value, fields):
|
||||
terms = []
|
||||
for index in range(0, len(fields), 2):
|
||||
key, expected = fields[index:index + 2]
|
||||
terms.extend((f"json_typeof(({value}) -> {key}) = 'string'", f"(({value}) ->> {key}) = {expected}"))
|
||||
return " AND ".join(terms)
|
||||
|
||||
|
||||
@compiles(_ArrayString, "sqlite")
|
||||
def _array_string_sqlite(element, compiler, **kwargs):
|
||||
value, expected = _parts(element, compiler, kwargs)
|
||||
return (
|
||||
f"EXISTS (SELECT 1 FROM json_each({_sqlite_array(value)}) AS gp_json_string "
|
||||
f"WHERE gp_json_string.type = 'text' AND gp_json_string.value = {expected})"
|
||||
)
|
||||
|
||||
|
||||
@compiles(_ArrayString, "postgresql")
|
||||
def _array_string_postgres(element, compiler, **kwargs):
|
||||
value, expected = _parts(element, compiler, kwargs)
|
||||
return (
|
||||
f"EXISTS (SELECT 1 FROM json_array_elements({_postgres_array(value)}) AS gp_json_string(value) "
|
||||
f"WHERE json_typeof(gp_json_string.value) = 'string' "
|
||||
f"AND (gp_json_string.value #>> '{{}}') = {expected})"
|
||||
)
|
||||
|
||||
|
||||
@compiles(_ObjectStrings, "sqlite")
|
||||
def _object_sqlite(element, compiler, **kwargs):
|
||||
value, *fields = _parts(element, compiler, kwargs)
|
||||
value = f"CASE WHEN json_type({value}) = 'object' THEN {value} ELSE '{{}}' END"
|
||||
return f"({_sqlite_fields(value, fields)})"
|
||||
|
||||
|
||||
@compiles(_ObjectStrings, "postgresql")
|
||||
def _object_postgres(element, compiler, **kwargs):
|
||||
value, *fields = _parts(element, compiler, kwargs)
|
||||
value = f"CAST({value} AS JSON)"
|
||||
return f"(json_typeof({value}) = 'object' AND {_postgres_fields(value, fields)})"
|
||||
|
||||
|
||||
@compiles(_ArrayObjectStrings, "sqlite")
|
||||
def _array_object_sqlite(element, compiler, **kwargs):
|
||||
value, *fields = _parts(element, compiler, kwargs)
|
||||
item = "CASE WHEN gp_json_object.type = 'object' THEN gp_json_object.value ELSE '{}' END"
|
||||
return (
|
||||
f"EXISTS (SELECT 1 FROM json_each({_sqlite_array(value)}) AS gp_json_object "
|
||||
f"WHERE {_sqlite_fields(item, fields)})"
|
||||
)
|
||||
|
||||
|
||||
@compiles(_ArrayObjectStrings, "postgresql")
|
||||
def _array_object_postgres(element, compiler, **kwargs):
|
||||
value, *fields = _parts(element, compiler, kwargs)
|
||||
return (
|
||||
f"EXISTS (SELECT 1 FROM json_array_elements({_postgres_array(value)}) AS gp_json_object(value) "
|
||||
f"WHERE json_typeof(gp_json_object.value) = 'object' "
|
||||
f"AND {_postgres_fields('gp_json_object.value', fields)})"
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Resource-bounded, disposable workers for trusted module-owned byte operations.
|
||||
|
||||
This is not an arbitrary-code sandbox. Callers supply a server-owned top-level
|
||||
function, never a client-selected module/path/callable. Sessions, credentials and
|
||||
authority remain in the parent; only explicit bounded bytes cross the pipe.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
import inspect
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import selectors
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProcessLimits:
|
||||
wall_seconds: float = 10.0
|
||||
cpu_seconds: int = 10
|
||||
memory_bytes: int = 256 * 1024 * 1024
|
||||
input_bytes: int = 8 * 1024 * 1024
|
||||
output_bytes: int = 8 * 1024 * 1024
|
||||
file_bytes: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not math.isfinite(self.wall_seconds) or not 0 < self.wall_seconds <= 600:
|
||||
raise ValueError("Worker wall time must be finite and within (0, 600] seconds.")
|
||||
for name, minimum, maximum in (
|
||||
("cpu_seconds", 1, 600),
|
||||
("memory_bytes", 64 * 1024 * 1024, 8 * 1024 * 1024 * 1024),
|
||||
("input_bytes", 1, 256 * 1024 * 1024),
|
||||
("output_bytes", 1, 256 * 1024 * 1024),
|
||||
("file_bytes", 0, 2 * 1024 * 1024 * 1024),
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if type(value) is not int or not minimum <= value <= maximum:
|
||||
raise ValueError(f"Invalid worker {name} limit.")
|
||||
|
||||
|
||||
class ProcessBudgetError(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
messages = {
|
||||
"busy": "The isolated-work capacity is busy; retry later.",
|
||||
"cancelled": "Isolated work was cancelled.",
|
||||
"timeout": "Isolated work exceeded its wall-clock limit.",
|
||||
"cpu_limit": "Isolated work exceeded its CPU limit.",
|
||||
"memory_limit": "Isolated work exceeded its memory limit.",
|
||||
"input_limit": "Isolated work input exceeded its byte limit.",
|
||||
"output_limit": "Isolated work output exceeded its byte limit.",
|
||||
"unavailable": "Required isolated-worker resource controls are unavailable.",
|
||||
"worker_failed": "Isolated work could not complete safely.",
|
||||
}
|
||||
self.code = code
|
||||
super().__init__(messages[code])
|
||||
|
||||
|
||||
_gate = threading.Lock()
|
||||
_active = 0
|
||||
_STDERR_LIMIT = 64 * 1024
|
||||
|
||||
|
||||
def _reset_after_fork() -> None:
|
||||
global _gate, _active
|
||||
_gate, _active = threading.Lock(), 0
|
||||
|
||||
|
||||
if hasattr(os, "register_at_fork"):
|
||||
os.register_at_fork(after_in_child=_reset_after_fork)
|
||||
|
||||
|
||||
def _reserve() -> None:
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
global _active
|
||||
with _gate:
|
||||
if _active >= settings.isolated_process_concurrency:
|
||||
raise ProcessBudgetError("busy")
|
||||
_active += 1
|
||||
|
||||
|
||||
def _release() -> None:
|
||||
global _active
|
||||
with _gate:
|
||||
_active -= 1
|
||||
|
||||
|
||||
@dataclass(slots=True, eq=False)
|
||||
class _OperationAdmission:
|
||||
process_id: int
|
||||
thread_id: int
|
||||
active: bool = True
|
||||
running: bool = False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bounded_operation_admission():
|
||||
"""Reserve shared capacity before bounded parent-side input preparation.
|
||||
|
||||
The yielded token may be reused sequentially in this thread only. Never
|
||||
expose it to clients or hold it while waiting for user input/network work.
|
||||
Parent preparation still needs its own byte/time/disk bounds.
|
||||
"""
|
||||
_reserve()
|
||||
admission = _OperationAdmission(os.getpid(), threading.get_ident())
|
||||
try:
|
||||
yield admission
|
||||
finally:
|
||||
admission.active = False
|
||||
# Fork resets the child's admission counter. An inherited context must
|
||||
# still expire its token, but cannot release the parent's reservation.
|
||||
if admission.process_id == os.getpid():
|
||||
_release()
|
||||
|
||||
|
||||
def _enter_admission(admission: _OperationAdmission) -> None:
|
||||
with _gate:
|
||||
if (
|
||||
not isinstance(admission, _OperationAdmission)
|
||||
or not admission.active or admission.running
|
||||
or admission.process_id != os.getpid()
|
||||
or admission.thread_id != threading.get_ident()
|
||||
):
|
||||
raise ValueError("Worker admission must be active, unused and owned by this thread/process.")
|
||||
admission.running = True
|
||||
|
||||
|
||||
def run_bounded_operation(
|
||||
operation: Callable[[bytes], bytes],
|
||||
payload: bytes,
|
||||
*,
|
||||
limits: ProcessLimits = ProcessLimits(),
|
||||
cancelled: Callable[[], bool] | None = None,
|
||||
admission: _OperationAdmission | None = None,
|
||||
) -> bytes:
|
||||
"""Run a trusted, importable module function without inheriting parent state.
|
||||
|
||||
Admission is non-queuing and per API/worker process. POSIX process groups,
|
||||
resource limits and waitid(WNOWAIT) are required; no in-process fallback.
|
||||
Output and stderr are drained incrementally, including while input is sent.
|
||||
Every exit path kills the owned process group before reaping its leader.
|
||||
"""
|
||||
if type(payload) is not bytes or len(payload) > limits.input_bytes:
|
||||
raise ProcessBudgetError("input_limit")
|
||||
if os.name != "posix" or not hasattr(os, "WNOWAIT") or not hasattr(os, "waitid"):
|
||||
raise ProcessBudgetError("unavailable")
|
||||
module = inspect.getmodule(operation)
|
||||
name = getattr(operation, "__name__", "")
|
||||
if (
|
||||
module is None or not name.isidentifier()
|
||||
or getattr(module, name, None) is not operation
|
||||
or not inspect.isfunction(operation) or not getattr(module, "__file__", None)
|
||||
):
|
||||
raise ValueError("Isolated operations must be server-owned module-level functions.")
|
||||
module_name = module.__name__
|
||||
if not all(part.isidentifier() for part in module_name.split(".")):
|
||||
raise ValueError("Invalid isolated operation module.")
|
||||
# Explicit source identity supports installed modules and editable development
|
||||
# checkouts without inheriting arbitrary PYTHONPATH or the parent's cwd.
|
||||
source = Path(module.__file__).resolve()
|
||||
source_root = source.parents[len(module_name.split(".")) - 1]
|
||||
if source.name == "__init__.py":
|
||||
source_root = source_root.parent
|
||||
_check_cancelled(cancelled)
|
||||
if admission is None:
|
||||
with bounded_operation_admission() as reserved:
|
||||
return run_bounded_operation(
|
||||
operation, payload, limits=limits, cancelled=cancelled, admission=reserved,
|
||||
)
|
||||
_enter_admission(admission)
|
||||
try:
|
||||
return _run(module_name, name, source_root, payload, limits, cancelled)
|
||||
finally:
|
||||
admission.running = False
|
||||
|
||||
|
||||
def _check_cancelled(cancelled: Callable[[], bool] | None) -> None:
|
||||
if cancelled is not None and cancelled():
|
||||
raise ProcessBudgetError("cancelled")
|
||||
|
||||
|
||||
def _run(
|
||||
module: str, name: str, source_root: Path, payload: bytes,
|
||||
limits: ProcessLimits, cancelled: Callable[[], bool] | None,
|
||||
) -> bytes:
|
||||
command = [
|
||||
sys.executable, "-I", "-B", "-m", "govoplan_core.security.process_worker",
|
||||
module, name, str(source_root), str(limits.cpu_seconds),
|
||||
str(limits.memory_bytes), str(limits.input_bytes), str(limits.output_bytes),
|
||||
str(limits.file_bytes),
|
||||
]
|
||||
deadline = time.monotonic() + limits.wall_seconds
|
||||
try:
|
||||
process = subprocess.Popen( # noqa: S603 - fixed interpreter/bootstrap, trusted operation.
|
||||
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
bufsize=0, close_fds=True, start_new_session=True, cwd="/",
|
||||
env={
|
||||
"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "TZ": "UTC",
|
||||
"OPENBLAS_NUM_THREADS": "1", "OMP_NUM_THREADS": "1",
|
||||
"MKL_NUM_THREADS": "1", "NUMEXPR_NUM_THREADS": "1",
|
||||
},
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ProcessBudgetError("unavailable") from exc
|
||||
output = bytearray()
|
||||
sent = 0
|
||||
stderr_bytes = 0
|
||||
try:
|
||||
with selectors.DefaultSelector() as selector:
|
||||
for stream in (process.stdin, process.stdout, process.stderr):
|
||||
os.set_blocking(stream.fileno(), False)
|
||||
selector.register(process.stdout, selectors.EVENT_READ, "stdout")
|
||||
selector.register(process.stderr, selectors.EVENT_READ, "stderr")
|
||||
if payload:
|
||||
selector.register(process.stdin, selectors.EVENT_WRITE, "stdin")
|
||||
else:
|
||||
process.stdin.close()
|
||||
while True:
|
||||
_check_cancelled(cancelled)
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise ProcessBudgetError("timeout")
|
||||
# WNOWAIT keeps the owned group leader's PID reserved until the
|
||||
# group is killed, including descendants that close their pipes.
|
||||
exited = os.waitid(os.P_PID, process.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
|
||||
if exited is not None and not selector.get_map():
|
||||
break
|
||||
events = selector.select(min(remaining, 0.05))
|
||||
for key, _event in events:
|
||||
if key.data == "stdin":
|
||||
try:
|
||||
sent += os.write(key.fd, memoryview(payload)[sent:sent + 65536])
|
||||
except BrokenPipeError:
|
||||
sent = len(payload)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if sent == len(payload):
|
||||
selector.unregister(key.fileobj)
|
||||
key.fileobj.close()
|
||||
continue
|
||||
available = (
|
||||
limits.output_bytes - len(output)
|
||||
if key.data == "stdout" else _STDERR_LIMIT - stderr_bytes
|
||||
)
|
||||
try:
|
||||
chunk = os.read(key.fd, min(65536, available + 1))
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if not chunk:
|
||||
selector.unregister(key.fileobj)
|
||||
elif len(chunk) > available:
|
||||
raise ProcessBudgetError("output_limit")
|
||||
elif key.data == "stdout":
|
||||
output.extend(chunk)
|
||||
else:
|
||||
# Never expose raw exception/log output to a client.
|
||||
stderr_bytes += len(chunk)
|
||||
exit_code = os.waitstatus_to_exitcode(
|
||||
(exited.si_status << 8) if exited.si_code == os.CLD_EXITED else exited.si_status
|
||||
)
|
||||
finally:
|
||||
# Do not communicate(): it would collect unbounded output during cleanup.
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
finally:
|
||||
for stream in (process.stdin, process.stdout, process.stderr):
|
||||
stream.close()
|
||||
process.wait()
|
||||
if exit_code != 0:
|
||||
code = {
|
||||
71: "memory_limit", 72: "input_limit", 73: "output_limit",
|
||||
74: "unavailable", -signal.SIGXCPU: "cpu_limit",
|
||||
-signal.SIGXFSZ: "output_limit",
|
||||
}.get(exit_code, "worker_failed")
|
||||
raise ProcessBudgetError(code)
|
||||
return bytes(output)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Private child entry point; resource controls precede module-owned imports."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from io import BytesIO
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _limit(resource, kind: int, soft: int, hard: int | None = None) -> None:
|
||||
_old_soft, old_hard = resource.getrlimit(kind)
|
||||
selected_hard = soft if hard is None else hard
|
||||
if old_hard != resource.RLIM_INFINITY:
|
||||
selected_hard = min(selected_hard, old_hard)
|
||||
resource.setrlimit(kind, (min(soft, selected_hard), selected_hard))
|
||||
|
||||
|
||||
def _read_input(source, maximum: int) -> bytes | None:
|
||||
# read(maximum + 1) can reserve the entire configured cap for a tiny DTO.
|
||||
# Keep temporary reads small; BytesIO.getvalue() avoids a second full buffer
|
||||
# in CPython, and the input cap remains checked during collection.
|
||||
with BytesIO() as collected:
|
||||
total = 0
|
||||
while True:
|
||||
chunk = source.read(min(65536, maximum - total + 1))
|
||||
if not chunk:
|
||||
return collected.getvalue()
|
||||
total += len(chunk)
|
||||
if total > maximum:
|
||||
return None
|
||||
collected.write(chunk)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
module, name, source_root, cpu, memory, input_limit, output_limit, file_limit = sys.argv[1:]
|
||||
try:
|
||||
import resource
|
||||
|
||||
_limit(resource, resource.RLIMIT_CORE, 0)
|
||||
_limit(resource, resource.RLIMIT_AS, int(memory))
|
||||
_limit(resource, resource.RLIMIT_CPU, int(cpu), int(cpu) + 1)
|
||||
_limit(resource, resource.RLIMIT_FSIZE, int(file_limit))
|
||||
_limit(resource, resource.RLIMIT_NOFILE, 64)
|
||||
os.umask(0o077)
|
||||
except (ImportError, AttributeError, OSError, ValueError):
|
||||
return 74
|
||||
try:
|
||||
payload = _read_input(sys.stdin.buffer, int(input_limit))
|
||||
if payload is None:
|
||||
return 72
|
||||
sys.path.insert(0, source_root)
|
||||
operation = getattr(importlib.import_module(module), name)
|
||||
result = operation(payload)
|
||||
if type(result) is not bytes:
|
||||
return 70
|
||||
if len(result) > int(output_limit):
|
||||
return 73
|
||||
sys.stdout.buffer.write(result)
|
||||
sys.stdout.buffer.flush()
|
||||
return 0
|
||||
except MemoryError:
|
||||
return 71
|
||||
except BaseException:
|
||||
return 70
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Avoid arbitrary module atexit hooks extending completion beyond the budget.
|
||||
os._exit(main())
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Bounded data-only binary transport for disposable workers.
|
||||
|
||||
Static tags and unsigned 32-bit scalar lengths/container counts are validated
|
||||
before allocation. No object hooks, imports, pickle, or JSON object graph is used.
|
||||
Operation owners still validate their own DTO after decoding these basic types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal, DecimalException
|
||||
import struct
|
||||
from uuid import UUID
|
||||
|
||||
_MAGIC = b"GWP\x01"
|
||||
_MAX_DEPTH = 64
|
||||
_MAX_NODES = 1_000_000
|
||||
_TEXT_CHUNK = 16 * 1024
|
||||
_U32 = struct.Struct(">I")
|
||||
_NULL, _FALSE, _TRUE = 0, 1, 2
|
||||
_STR, _BYTES, _INT, _FLOAT, _DECIMAL, _UUID = 3, 4, 5, 6, 7, 8
|
||||
_DATE, _DATETIME, _TIME, _TUPLE, _LIST, _DICT = 9, 10, 11, 12, 13, 14
|
||||
|
||||
|
||||
class WorkerPayloadError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_limit(max_bytes: int) -> None:
|
||||
if type(max_bytes) is not int or not 0 <= max_bytes <= 0xFFFFFFFF:
|
||||
raise WorkerPayloadError("Invalid worker payload byte limit.")
|
||||
|
||||
|
||||
def encode_worker_payload(value: object, *, max_bytes: int = 32 * 1024 * 1024) -> bytes:
|
||||
_validate_limit(max_bytes)
|
||||
wire = bytearray()
|
||||
nodes = 0
|
||||
|
||||
def append(data: bytes) -> None:
|
||||
if len(data) > max_bytes - len(wire):
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
wire.extend(data)
|
||||
|
||||
def scalar(tag: int, item: str | bytes) -> None:
|
||||
append(bytes((tag,)))
|
||||
length_position = len(wire)
|
||||
append(b"\x00\x00\x00\x00")
|
||||
value_position = len(wire)
|
||||
if type(item) is bytes:
|
||||
append(item)
|
||||
else:
|
||||
# UTF-8 needs at least one byte per code point. Never construct
|
||||
# a whole escaped or encoded copy before checking the byte cap.
|
||||
if len(item) > max_bytes - len(wire):
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
for offset in range(0, len(item), _TEXT_CHUNK):
|
||||
append(item[offset : offset + _TEXT_CHUNK].encode("utf-8"))
|
||||
_U32.pack_into(wire, length_position, len(wire) - value_position)
|
||||
|
||||
def encode(item: object, depth: int = 0) -> None:
|
||||
nonlocal nodes
|
||||
nodes += 1
|
||||
if depth > _MAX_DEPTH or nodes > _MAX_NODES:
|
||||
raise WorkerPayloadError("Worker payload exceeds its structural limit.")
|
||||
kind = type(item)
|
||||
if item is None:
|
||||
append(bytes((_NULL,)))
|
||||
elif kind is bool:
|
||||
append(bytes((_TRUE if item else _FALSE,)))
|
||||
elif kind is str:
|
||||
scalar(_STR, item)
|
||||
elif kind is bytes:
|
||||
scalar(_BYTES, item)
|
||||
elif kind is int:
|
||||
length = (item.bit_length() + 8) // 8
|
||||
if length > max_bytes - len(wire) - 5:
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
scalar(_INT, item.to_bytes(length, "big", signed=True))
|
||||
elif kind is float:
|
||||
scalar(_FLOAT, struct.pack(">d", item))
|
||||
elif kind is Decimal:
|
||||
# C Decimal uses at least 8 bytes per 19 coefficient digits.
|
||||
# This conservative bound includes inline digits and exponent text,
|
||||
# avoiding an unbounded str() or as_tuple() allocation in the parent.
|
||||
if item.__sizeof__() * 3 + 64 > max_bytes - len(wire):
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
scalar(_DECIMAL, str(item))
|
||||
elif kind is UUID:
|
||||
scalar(_UUID, item.bytes)
|
||||
elif kind is date:
|
||||
scalar(_DATE, item.isoformat())
|
||||
elif kind is datetime:
|
||||
scalar(_DATETIME, item.isoformat())
|
||||
elif kind is time:
|
||||
scalar(_TIME, item.isoformat())
|
||||
elif kind in (tuple, list, dict):
|
||||
count = len(item)
|
||||
children = count * (2 if kind is dict else 1)
|
||||
if count > 0xFFFFFFFF or children > _MAX_NODES - nodes:
|
||||
raise WorkerPayloadError("Worker payload exceeds its structural limit.")
|
||||
if children > max_bytes - len(wire) - 5:
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
append(bytes(({tuple: _TUPLE, list: _LIST, dict: _DICT}[kind],)))
|
||||
append(_U32.pack(count))
|
||||
if kind is dict:
|
||||
for key, child in item.items():
|
||||
if type(key) is not str:
|
||||
raise WorkerPayloadError("Worker mapping keys must be strings.")
|
||||
encode(key, depth + 1)
|
||||
encode(child, depth + 1)
|
||||
else:
|
||||
for child in item:
|
||||
encode(child, depth + 1)
|
||||
else:
|
||||
raise WorkerPayloadError("Unsupported worker payload value type.")
|
||||
|
||||
try:
|
||||
append(_MAGIC)
|
||||
encode(value)
|
||||
return bytes(wire)
|
||||
except (ValueError, OverflowError, RecursionError, DecimalException) as exc:
|
||||
if isinstance(exc, WorkerPayloadError):
|
||||
raise
|
||||
raise WorkerPayloadError("Invalid worker payload value.") from exc
|
||||
|
||||
|
||||
def decode_worker_payload(
|
||||
payload: bytes, *, max_bytes: int = 32 * 1024 * 1024
|
||||
) -> object:
|
||||
_validate_limit(max_bytes)
|
||||
if type(payload) is not bytes or len(payload) > max_bytes:
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
if not payload.startswith(_MAGIC):
|
||||
raise WorkerPayloadError("Unknown worker payload format.")
|
||||
wire = memoryview(payload)
|
||||
cursor = len(_MAGIC)
|
||||
nodes = 0
|
||||
|
||||
def take(size: int) -> memoryview:
|
||||
nonlocal cursor
|
||||
if size > len(wire) - cursor:
|
||||
raise WorkerPayloadError("Truncated worker payload.")
|
||||
start = cursor
|
||||
cursor += size
|
||||
return wire[start:cursor]
|
||||
|
||||
def decode(depth: int = 0) -> object:
|
||||
nonlocal nodes
|
||||
nodes += 1
|
||||
if depth > _MAX_DEPTH or nodes > _MAX_NODES:
|
||||
raise WorkerPayloadError("Worker payload exceeds its structural limit.")
|
||||
tag = take(1)[0]
|
||||
if tag == _NULL:
|
||||
return None
|
||||
if tag in (_FALSE, _TRUE):
|
||||
return tag == _TRUE
|
||||
if tag not in range(_STR, _DICT + 1):
|
||||
raise WorkerPayloadError("Unknown worker payload tag.")
|
||||
length = _U32.unpack(take(4))[0]
|
||||
if tag in (_TUPLE, _LIST, _DICT):
|
||||
children = length * (2 if tag == _DICT else 1)
|
||||
if children > _MAX_NODES - nodes or children > len(wire) - cursor:
|
||||
raise WorkerPayloadError("Invalid worker container count.")
|
||||
if tag == _DICT:
|
||||
result = {}
|
||||
for _index in range(length):
|
||||
key = decode(depth + 1)
|
||||
if type(key) is not str or key in result:
|
||||
raise WorkerPayloadError(
|
||||
"Invalid or duplicate worker mapping key."
|
||||
)
|
||||
result[key] = decode(depth + 1)
|
||||
return result
|
||||
result = [decode(depth + 1) for _index in range(length)]
|
||||
return tuple(result) if tag == _TUPLE else result
|
||||
value = take(length)
|
||||
if tag == _BYTES:
|
||||
return bytes(value)
|
||||
if tag == _INT:
|
||||
if not length:
|
||||
raise WorkerPayloadError("Invalid worker integer.")
|
||||
return int.from_bytes(value, "big", signed=True)
|
||||
if tag == _FLOAT:
|
||||
if length != 8:
|
||||
raise WorkerPayloadError("Invalid worker float.")
|
||||
return struct.unpack(">d", value)[0]
|
||||
if tag == _UUID:
|
||||
if length != 16:
|
||||
raise WorkerPayloadError("Invalid worker UUID.")
|
||||
return UUID(bytes=bytes(value))
|
||||
text = str(value, "utf-8")
|
||||
decoders = {
|
||||
_STR: lambda item: item,
|
||||
_DECIMAL: Decimal,
|
||||
_DATE: date.fromisoformat,
|
||||
_DATETIME: datetime.fromisoformat,
|
||||
_TIME: time.fromisoformat,
|
||||
}
|
||||
return decoders[tag](text)
|
||||
|
||||
try:
|
||||
result = decode()
|
||||
if cursor != len(wire):
|
||||
raise WorkerPayloadError("Trailing worker payload data.")
|
||||
return result
|
||||
except (
|
||||
ValueError,
|
||||
TypeError,
|
||||
RecursionError,
|
||||
OverflowError,
|
||||
DecimalException,
|
||||
) as exc:
|
||||
if isinstance(exc, WorkerPayloadError):
|
||||
raise
|
||||
raise WorkerPayloadError("Invalid worker payload.") from exc
|
||||
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
JSON_CACHE_CONTROL = "private, no-cache"
|
||||
# This bounds middleware-owned buffering, not the size of a route response.
|
||||
# Larger responses retain their streaming iterator and are never truncated.
|
||||
MAX_CONDITIONAL_JSON_BYTES = 1_048_576
|
||||
JSON_ETAG_VARY_HEADERS = (
|
||||
"Authorization",
|
||||
"Cookie",
|
||||
@@ -26,15 +29,34 @@ async def conditional_json_get_middleware(
|
||||
|
||||
The middleware deliberately works after route handling. That keeps the
|
||||
contract platform-wide without requiring every module router to learn about
|
||||
conditional requests, while still limiting buffering to successful JSON GET
|
||||
responses.
|
||||
conditional requests. Only small successful JSON responses are buffered;
|
||||
larger responses stream unchanged. Authorization still runs on every GET.
|
||||
"""
|
||||
|
||||
response = await call_next(request)
|
||||
if not _eligible_for_conditional_json_get(request, response):
|
||||
return response
|
||||
|
||||
body = b"".join([chunk async for chunk in response.body_iterator])
|
||||
response.headers["cache-control"] = _conditional_cache_control(response.headers.get("cache-control"))
|
||||
response.headers["vary"] = _merge_vary(response.headers.get("vary"), JSON_ETAG_VARY_HEADERS)
|
||||
content_length = response.headers.get("content-length", "")
|
||||
if content_length.isascii() and content_length.isdecimal() and int(content_length) > MAX_CONDITIONAL_JSON_BYTES:
|
||||
return response
|
||||
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
iterator = response.body_iterator
|
||||
async for chunk in iterator:
|
||||
if not chunk:
|
||||
continue
|
||||
chunks.append(chunk)
|
||||
size += len(chunk)
|
||||
if size > MAX_CONDITIONAL_JSON_BYTES:
|
||||
# Include the crossing chunk exactly once, without draining the
|
||||
# rest of the producer or copying a potentially large chunk.
|
||||
response.body_iterator = _replay_prefix(chunks, iterator)
|
||||
return response
|
||||
body = b"".join(chunks)
|
||||
etag = response.headers.get("etag") or json_response_etag(body)
|
||||
headers = dict(response.headers)
|
||||
headers["etag"] = etag
|
||||
@@ -48,6 +70,14 @@ async def conditional_json_get_middleware(
|
||||
return Response(content=body, status_code=response.status_code, headers=headers, background=response.background)
|
||||
|
||||
|
||||
async def _replay_prefix(chunks: list[bytes], iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
|
||||
for chunk in chunks:
|
||||
yield chunk
|
||||
chunks.clear()
|
||||
async for chunk in iterator:
|
||||
yield chunk
|
||||
|
||||
|
||||
def json_response_etag(body: bytes) -> str:
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
return f'W/"sha256-{digest}"'
|
||||
|
||||
@@ -40,6 +40,12 @@ class Settings(BaseSettings):
|
||||
le=1000,
|
||||
alias="GOVOPLAN_EXPECTED_WORKER_REPLICAS",
|
||||
)
|
||||
isolated_process_concurrency: int = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
le=16,
|
||||
alias="GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY",
|
||||
)
|
||||
module_live_apply_enabled: bool | None = Field(
|
||||
default=None,
|
||||
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
|
||||
@@ -214,6 +220,11 @@ class Settings(BaseSettings):
|
||||
alias="TENANT_MODULE_ENTITLEMENT_CACHE_MAX_ENTRIES",
|
||||
)
|
||||
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
||||
# Enable only after the administrator-assisted identity-verification and
|
||||
# recovery-code handoff policy has been adopted for this installation.
|
||||
auth_local_password_recovery_enabled: bool = Field(
|
||||
default=False, alias="AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED"
|
||||
)
|
||||
auth_login_throttle_identity_limit: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Synthetic operations, imported only by isolated tests; no application effects."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def echo(payload: bytes) -> bytes:
|
||||
return payload
|
||||
|
||||
|
||||
def wait(payload: bytes) -> bytes:
|
||||
time.sleep(float(payload))
|
||||
return b"done"
|
||||
|
||||
|
||||
def regex_stall(_payload: bytes) -> bytes:
|
||||
re.fullmatch(r"(a+)+$", "a" * 100 + "!")
|
||||
return b"unreachable"
|
||||
|
||||
|
||||
def allocate(_payload: bytes) -> bytes:
|
||||
return b"x" * (256 * 1024 * 1024)
|
||||
|
||||
|
||||
def too_much_stdout(_payload: bytes) -> bytes:
|
||||
while True:
|
||||
os.write(1, b"x" * 65536)
|
||||
|
||||
|
||||
def too_much_stderr(_payload: bytes) -> bytes:
|
||||
while True:
|
||||
os.write(2, b"sensitive synthetic log" * 4096)
|
||||
|
||||
|
||||
def fail(_payload: bytes) -> bytes:
|
||||
raise ValueError("private synthetic data must not become an error response")
|
||||
|
||||
|
||||
def close_pipes_then_wait(_payload: bytes) -> bytes:
|
||||
os.close(1)
|
||||
os.close(2)
|
||||
time.sleep(30)
|
||||
return b""
|
||||
|
||||
|
||||
def observe(_payload: bytes) -> bytes:
|
||||
import resource
|
||||
|
||||
return json.dumps({
|
||||
"pid": os.getpid(), "pgid": os.getpgrp(), "sid": os.getsid(0),
|
||||
"cpu": resource.getrlimit(resource.RLIMIT_CPU),
|
||||
"memory": resource.getrlimit(resource.RLIMIT_AS),
|
||||
"file": resource.getrlimit(resource.RLIMIT_FSIZE),
|
||||
"core": resource.getrlimit(resource.RLIMIT_CORE),
|
||||
"env": sorted(os.environ), "cwd": os.getcwd(),
|
||||
}).encode()
|
||||
|
||||
|
||||
def child_with_closed_pipes(_payload: bytes) -> bytes:
|
||||
child = subprocess.Popen(
|
||||
[sys.executable, "-I", "-c", "import time; time.sleep(30)"],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return str(child.pid).encode()
|
||||
+50
-6
@@ -8,10 +8,11 @@ import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pyzipper
|
||||
@@ -3545,13 +3546,17 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(len(jobs.json()["jobs"]), 1)
|
||||
job_summary = jobs.json()["jobs"][0]
|
||||
self.assertEqual(job_summary["campaign_version_id"], version_id)
|
||||
self.assertNotIn("resolved_recipients", job_summary)
|
||||
self.assertEqual(job_summary["resolved_recipients"]["to"][0]["email"], "recipient@example.org")
|
||||
self.assertNotIn("attachments", job_summary)
|
||||
detail = self.client.get(
|
||||
f"/api/v1/campaigns/{campaign_id}/jobs/{job_summary['id']}",
|
||||
headers=headers,
|
||||
)
|
||||
self.assertEqual(detail.status_code, 200, detail.text)
|
||||
job = detail.json()["job"]
|
||||
self.assertEqual(job_summary["resolved_recipients"], {
|
||||
kind: job["resolved_recipients"][kind] for kind in ("to", "cc", "bcc")
|
||||
})
|
||||
self.assertEqual(job["resolved_recipients"]["to"][0]["email"], "recipient@example.org")
|
||||
self.assertEqual(
|
||||
{
|
||||
@@ -4613,7 +4618,9 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(queued_summary.json()["status_counts"]["send"]["queued"], 2)
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, SendAttempt
|
||||
from govoplan_campaign.backend.sending.jobs import send_campaign_job
|
||||
from govoplan_campaign.backend.sending.jobs import _begin_job_delivery_recovery, send_campaign_job
|
||||
from govoplan_campaign.backend.services.delivery_recovery import job_recovery_metadata
|
||||
from govoplan_core.core.runtime_coordination import DistributedLease, RuntimeNode, process_runtime_identity
|
||||
|
||||
with SessionLocal() as session:
|
||||
jobs = (
|
||||
@@ -4645,7 +4652,42 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
|
||||
with SessionLocal() as session:
|
||||
result = send_campaign_job(session, job_id=uncertain_job_id, use_rate_limit=False)
|
||||
self.assertEqual(result.status, "outcome_unknown")
|
||||
# Observing SENDING is not proof that its owner has stopped.
|
||||
self.assertEqual(result.status, "already_sending")
|
||||
job = session.get(CampaignJob, uncertain_job_id)
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
recovery = _begin_job_delivery_recovery(
|
||||
job=job, context=SimpleNamespace(version=version), claim_token=job.claim_token,
|
||||
)
|
||||
self.assertTrue(recovery.operation_id)
|
||||
session.expire_all()
|
||||
lease = session.query(DistributedLease).filter(
|
||||
DistributedLease.resource_key == f"campaign:delivery:{job.tenant_id}:{job.id}",
|
||||
).one()
|
||||
lease.holder_node_id = "smoke-stopped-worker"
|
||||
lease.holder_incarnation = "smoke-old-incarnation"
|
||||
lease.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
session.add(RuntimeNode(
|
||||
installation_id=process_runtime_identity().installation_id,
|
||||
node_id="smoke-stopped-worker", incarnation="smoke-old-incarnation",
|
||||
role="worker", software_version="test", composition_hash="c" * 64,
|
||||
state="stopped",
|
||||
))
|
||||
session.commit()
|
||||
# Read the committed representation, just as an independent HTTP
|
||||
# reader does (SQLite drops timezone objects during persistence).
|
||||
session.expire_all()
|
||||
metadata = job_recovery_metadata(session, [job])[job.id]["smtp"]
|
||||
self.assertTrue(metadata["eligible"])
|
||||
|
||||
recovered = self.client.post(
|
||||
f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}/recover-claim",
|
||||
headers=headers,
|
||||
json={"channel": "smtp", "expected_revision": metadata["revision"],
|
||||
"note": "Fixture worker is confirmed stopped; inspect provider evidence next."},
|
||||
)
|
||||
self.assertEqual(recovered.status_code, 200, recovered.text)
|
||||
self.assertTrue(recovered.json()["result"]["reconciliation_required"])
|
||||
|
||||
retry_unknown = self.client.post(
|
||||
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
|
||||
@@ -4672,7 +4714,8 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(page.json()["total"], 2)
|
||||
self.assertEqual(page.json()["pages"], 2)
|
||||
self.assertEqual(page.json()["counts"]["send"]["outcome_unknown"], 1)
|
||||
self.assertNotIn("resolved_recipients", page.json()["jobs"][0])
|
||||
self.assertIn("resolved_recipients", page.json()["jobs"][0])
|
||||
self.assertNotIn("attachments", page.json()["jobs"][0])
|
||||
|
||||
filtered_page = self.client.get(
|
||||
f"/api/v1/campaigns/{campaign_id}/jobs",
|
||||
@@ -4851,7 +4894,8 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(first_jobs.json()["total_unfiltered"], 1)
|
||||
self.assertEqual(first_jobs.json()["review"]["required_count"], 0)
|
||||
self.assertIn("reviewed", first_jobs.json()["jobs"][0])
|
||||
self.assertNotIn("resolved_recipients", first_jobs.json()["jobs"][0])
|
||||
self.assertEqual(first_jobs.json()["jobs"][0]["resolved_recipients"]["to"][0]["email"], "recipient-1@example.org")
|
||||
self.assertNotIn("attachments", first_jobs.json()["jobs"][0])
|
||||
|
||||
first_csv = self.client.get(
|
||||
f"/api/v1/campaigns/{campaign_id}/report/jobs.csv",
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import replace
|
||||
from datetime import date, datetime, time as daytime, timezone
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
|
||||
from govoplan_core.security import bounded_process
|
||||
from govoplan_core.security.bounded_process import (
|
||||
ProcessBudgetError, ProcessLimits, bounded_operation_admission, run_bounded_operation,
|
||||
)
|
||||
from govoplan_core.security.worker_payload import (
|
||||
WorkerPayloadError, decode_worker_payload, encode_worker_payload,
|
||||
)
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_core.security.process_worker import _read_input
|
||||
from tests import bounded_process_fixtures as operations
|
||||
|
||||
|
||||
class BoundedProcessTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.processes = []
|
||||
original = subprocess.Popen
|
||||
|
||||
def start(*args, **kwargs):
|
||||
process = original(*args, **kwargs)
|
||||
self.processes.append(process)
|
||||
return process
|
||||
|
||||
self.patcher = patch.object(bounded_process.subprocess, "Popen", start)
|
||||
self.patcher.start()
|
||||
self.addCleanup(self.patcher.stop)
|
||||
self.addCleanup(self.assert_reaped)
|
||||
|
||||
def assert_reaped(self) -> None:
|
||||
for process in self.processes:
|
||||
self.assertIsNotNone(process.returncode)
|
||||
self.assertTrue(all(stream.closed for stream in (process.stdin, process.stdout, process.stderr)))
|
||||
with self.assertRaises(ChildProcessError):
|
||||
os.waitpid(process.pid, os.WNOHANG)
|
||||
self.assertEqual(bounded_process._active, 0)
|
||||
|
||||
def test_roundtrip_empty_and_pipe_sized_input_exact_output_limit(self) -> None:
|
||||
for payload in (b"", b"x" * 200_000):
|
||||
with self.subTest(length=len(payload)):
|
||||
result = run_bounded_operation(operations.echo, payload, limits=ProcessLimits(output_bytes=max(1, len(payload))))
|
||||
self.assertEqual(result, payload)
|
||||
|
||||
def test_controls_and_environment_are_applied_before_operation(self) -> None:
|
||||
with patch.dict(os.environ, {"DATABASE_URL": "synthetic-secret", "PYTHONPATH": "/untrusted", "SYNTHETIC_SECRET": "no"}):
|
||||
result = json.loads(run_bounded_operation(operations.observe, b"", limits=ProcessLimits(cpu_seconds=3)))
|
||||
self.assertEqual(result["pid"], result["pgid"])
|
||||
self.assertEqual(result["pid"], result["sid"])
|
||||
self.assertNotEqual(result["pid"], os.getpid())
|
||||
self.assertEqual(result["cpu"], [3, 4])
|
||||
self.assertEqual(result["memory"], [256 * 1024 * 1024] * 2)
|
||||
self.assertEqual(result["file"], [0, 0])
|
||||
self.assertEqual(result["core"], [0, 0])
|
||||
self.assertEqual(result["cwd"], "/")
|
||||
self.assertFalse({"DATABASE_URL", "PYTHONPATH", "SYNTHETIC_SECRET"} & set(result["env"]))
|
||||
|
||||
def test_real_regex_cpu_is_stopped_before_long_wall_limit(self) -> None:
|
||||
started = time.monotonic()
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operations.regex_stall, b"", limits=ProcessLimits(cpu_seconds=1, wall_seconds=5))
|
||||
self.assertEqual(raised.exception.code, "cpu_limit")
|
||||
self.assertLess(time.monotonic() - started, 4)
|
||||
|
||||
def test_memory_failure_never_allocates_the_large_result_in_parent(self) -> None:
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operations.allocate, b"", limits=ProcessLimits(memory_bytes=64 * 1024 * 1024))
|
||||
self.assertEqual(raised.exception.code, "memory_limit")
|
||||
|
||||
def test_noisy_stdout_and_stderr_are_bounded_during_execution(self) -> None:
|
||||
for operation in (operations.too_much_stdout, operations.too_much_stderr):
|
||||
with self.subTest(operation=operation.__name__):
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operation, b"", limits=ProcessLimits(output_bytes=1024, wall_seconds=3))
|
||||
self.assertEqual(raised.exception.code, "output_limit")
|
||||
|
||||
def test_sleep_and_closed_pipe_hangs_are_timed_out(self) -> None:
|
||||
for operation in (operations.wait, operations.close_pipes_then_wait):
|
||||
with self.subTest(operation=operation.__name__):
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operation, b"30", limits=ProcessLimits(wall_seconds=0.3))
|
||||
self.assertEqual(raised.exception.code, "timeout")
|
||||
|
||||
def test_cancellation_kills_and_reaps(self) -> None:
|
||||
started = time.monotonic()
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operations.wait, b"30", cancelled=lambda: time.monotonic() - started > 0.2)
|
||||
self.assertEqual(raised.exception.code, "cancelled")
|
||||
|
||||
def test_cancellation_callback_exception_also_cleans_up(self) -> None:
|
||||
calls = 0
|
||||
|
||||
def cancel():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls > 2:
|
||||
raise KeyboardInterrupt
|
||||
return False
|
||||
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
run_bounded_operation(operations.wait, b"30", cancelled=cancel)
|
||||
|
||||
def test_failure_does_not_return_child_exception_or_partial_output(self) -> None:
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operations.fail, b"")
|
||||
self.assertEqual(raised.exception.code, "worker_failed")
|
||||
self.assertNotIn("private", str(raised.exception))
|
||||
|
||||
def test_descendant_is_stopped_even_after_successful_leader_exit(self) -> None:
|
||||
pid = int(run_bounded_operation(operations.child_with_closed_pipes, b""))
|
||||
deadline = time.monotonic() + 2
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
state = Path(f"/proc/{pid}/stat").read_text().split(") ", 1)[1].split()[0]
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return
|
||||
if state == "Z":
|
||||
return # stopped, awaiting the operating system's orphan reaper
|
||||
time.sleep(0.01)
|
||||
self.fail("Descendant is still running after its owned group was cleaned up.")
|
||||
|
||||
def test_admission_is_bounded_and_releases_capacity(self) -> None:
|
||||
entered = threading.Event()
|
||||
|
||||
def pending():
|
||||
return run_bounded_operation(operations.wait, b"0.4", cancelled=lambda: entered.set() and False)
|
||||
|
||||
with patch.object(settings, "isolated_process_concurrency", 1), ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future = executor.submit(pending)
|
||||
entered.wait(1)
|
||||
deadline = time.monotonic() + 1
|
||||
while bounded_process._active == 0 and time.monotonic() < deadline:
|
||||
time.sleep(0.001)
|
||||
with self.assertRaises(ProcessBudgetError) as raised:
|
||||
run_bounded_operation(operations.echo, b"denied")
|
||||
self.assertEqual(raised.exception.code, "busy")
|
||||
self.assertEqual(future.result(), b"done")
|
||||
self.assertEqual(run_bounded_operation(operations.echo, b"after"), b"after")
|
||||
|
||||
def test_invalid_inputs_never_spawn_a_child(self) -> None:
|
||||
with self.assertRaises(ProcessBudgetError):
|
||||
run_bounded_operation(operations.echo, b"too large", limits=ProcessLimits(input_bytes=1))
|
||||
with self.assertRaises(ValueError):
|
||||
run_bounded_operation(lambda value: value, b"")
|
||||
self.assertEqual(self.processes, [])
|
||||
for changes in ({"wall_seconds": float("nan")}, {"wall_seconds": float("inf")}, {"cpu_seconds": True}, {"memory_bytes": 1}):
|
||||
with self.subTest(changes=changes), self.assertRaises(ValueError):
|
||||
replace(ProcessLimits(), **changes)
|
||||
|
||||
def test_explicit_admission_covers_preparation_and_can_be_reused_sequentially(self) -> None:
|
||||
with patch.object(settings, "isolated_process_concurrency", 1):
|
||||
with bounded_operation_admission() as admission:
|
||||
self.assertEqual(bounded_process._active, 1)
|
||||
with self.assertRaises(ProcessBudgetError) as busy:
|
||||
with bounded_operation_admission():
|
||||
self.fail("Preparation should not start without shared capacity.")
|
||||
self.assertEqual(busy.exception.code, "busy")
|
||||
self.assertEqual(run_bounded_operation(operations.echo, b"first", admission=admission), b"first")
|
||||
self.assertEqual(run_bounded_operation(operations.echo, b"second", admission=admission), b"second")
|
||||
self.assertEqual(bounded_process._active, 0)
|
||||
with self.assertRaises(ValueError):
|
||||
run_bounded_operation(operations.echo, b"expired", admission=admission)
|
||||
|
||||
def test_explicit_admission_rejects_other_threads_and_overlapping_reuse(self) -> None:
|
||||
with bounded_operation_admission() as admission:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(run_bounded_operation, operations.echo, b"wrong thread", admission=admission)
|
||||
with self.assertRaises(ValueError):
|
||||
future.result()
|
||||
nested = False
|
||||
def poll():
|
||||
nonlocal nested
|
||||
if admission.running and not nested:
|
||||
nested = True
|
||||
with self.assertRaises(ValueError):
|
||||
run_bounded_operation(operations.echo, b"overlap", admission=admission)
|
||||
return False
|
||||
self.assertEqual(run_bounded_operation(operations.wait, b"0.1", admission=admission, cancelled=poll), b"done")
|
||||
self.assertTrue(nested)
|
||||
|
||||
def test_preparation_failure_releases_capacity_without_spawning(self) -> None:
|
||||
with self.assertRaisesRegex(RuntimeError, "prepare"):
|
||||
with bounded_operation_admission():
|
||||
raise RuntimeError("prepare")
|
||||
self.assertEqual(bounded_process._active, 0)
|
||||
self.assertEqual(self.processes, [])
|
||||
|
||||
@unittest.skipUnless(hasattr(os, "fork"), "Fork ownership requires POSIX fork")
|
||||
def test_inherited_admission_expires_without_releasing_child_capacity(self) -> None:
|
||||
read_fd, write_fd = os.pipe()
|
||||
try:
|
||||
with bounded_operation_admission() as admission:
|
||||
child_pid = os.fork()
|
||||
if child_pid == 0:
|
||||
os.close(read_fd)
|
||||
if child_pid == 0:
|
||||
try:
|
||||
os.write(write_fd, json.dumps({
|
||||
"active": bounded_process._active,
|
||||
"token_active": admission.active,
|
||||
}).encode())
|
||||
finally:
|
||||
os.close(write_fd)
|
||||
os._exit(0)
|
||||
os.close(write_fd)
|
||||
write_fd = None
|
||||
observation = json.loads(os.read(read_fd, 256))
|
||||
_, status = os.waitpid(child_pid, 0)
|
||||
self.assertEqual(0, os.waitstatus_to_exitcode(status))
|
||||
self.assertEqual({"active": 0, "token_active": False}, observation)
|
||||
self.assertEqual(0, bounded_process._active)
|
||||
finally:
|
||||
os.close(read_fd)
|
||||
if write_fd is not None:
|
||||
os.close(write_fd)
|
||||
|
||||
def test_worker_stdin_reads_are_incremental_instead_of_allocating_the_cap(self) -> None:
|
||||
class ObservedInput(BytesIO):
|
||||
def read(self, size=-1):
|
||||
self_test.assertLessEqual(size, 65536)
|
||||
return super().read(size)
|
||||
self_test = self
|
||||
self.assertEqual(_read_input(ObservedInput(b"tiny"), 64 * 1024 * 1024), b"tiny")
|
||||
self.assertEqual(_read_input(ObservedInput(b"x" * 100000), 100000), b"x" * 100000)
|
||||
self.assertIsNone(_read_input(ObservedInput(b"x" * 100001), 100000))
|
||||
|
||||
|
||||
class WorkerPayloadTests(unittest.TestCase):
|
||||
def test_roundtrip_explicit_types_and_user_keys_cannot_impersonate_tags(self) -> None:
|
||||
value = {"str": ["bytes", "not transport"], "values": (
|
||||
None, True, 3, 1.25, Decimal("1.2500"), b"\x00\xff", date(2026, 9, 8),
|
||||
datetime(2026, 9, 8, tzinfo=timezone.utc), daytime(12, 30), UUID(int=4),
|
||||
)}
|
||||
self.assertEqual(decode_worker_payload(encode_worker_payload(value)), value)
|
||||
|
||||
def test_rejects_arbitrary_objects_duplicate_keys_and_invalid_tags(self) -> None:
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload(object())
|
||||
key = encode_worker_payload("a")[4:]
|
||||
duplicate_keys = b"GWP\x01\x0e\x00\x00\x00\x02" + (key + b"\x00") * 2
|
||||
for wire in (b'["pickle","payload"]', duplicate_keys, b"GWP\x01\xff", b"GWP\x01\x03\x00\x00\x00\x01\xff"):
|
||||
with self.subTest(wire=wire), self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(wire)
|
||||
|
||||
def test_transport_depth_and_byte_limits(self) -> None:
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload("x" * 1000, max_bytes=100)
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(b" " * 1000, max_bytes=100)
|
||||
value = []
|
||||
for _index in range(66):
|
||||
value = [value]
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload(value)
|
||||
|
||||
def test_astral_unicode_uses_utf8_bytes_and_exact_byte_caps(self) -> None:
|
||||
value = "\U0001f30d" * 32_769
|
||||
limit = 4 + 5 + len(value) * 4
|
||||
wire = encode_worker_payload(value, max_bytes=limit)
|
||||
self.assertEqual(len(wire), limit)
|
||||
self.assertEqual(value, decode_worker_payload(wire, max_bytes=limit))
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload(value, max_bytes=limit - 1)
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(wire, max_bytes=limit - 1)
|
||||
|
||||
def test_unicode_limit_is_checked_without_whole_encoded_temporary(self) -> None:
|
||||
import tracemalloc
|
||||
|
||||
value = "\U0001f30d" * 900_000
|
||||
tracemalloc.start()
|
||||
try:
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload(value, max_bytes=1_000_000)
|
||||
_current, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
self.assertLess(peak, 2_000_000)
|
||||
|
||||
def test_malformed_counts_depth_and_trailing_data_fail_before_children(self) -> None:
|
||||
import struct
|
||||
|
||||
deep = b"GWP\x01" + (b"\x0d" + struct.pack(">I", 1)) * 66 + b"\x00"
|
||||
for wire in (
|
||||
b"GWP\x01\x0d" + struct.pack(">I", 0xFFFFFFFF),
|
||||
b"GWP\x01\x0e" + struct.pack(">I", 0xFFFFFFFF),
|
||||
b"GWP\x01\x03" + struct.pack(">I", 0xFFFFFFFF),
|
||||
deep,
|
||||
encode_worker_payload(None) + b"\x00",
|
||||
b"GWP\x01\x05\x00\x00\x00\x00",
|
||||
b"GWP\x01\x06\x00\x00\x00\x01x",
|
||||
b"GWP\x01\x08\x00\x00\x00\x01x",
|
||||
):
|
||||
with self.subTest(wire=wire[:20]), self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(wire)
|
||||
|
||||
def test_decoder_checks_node_budget_before_allocating_container(self) -> None:
|
||||
from govoplan_core.security import worker_payload
|
||||
|
||||
wire = encode_worker_payload([None] * 20)
|
||||
with patch.object(worker_payload, "_MAX_NODES", 10):
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(wire)
|
||||
|
||||
def test_large_signed_integers_roundtrip_and_decimal_errors_are_normalized(self) -> None:
|
||||
for value in (0, -1, 127, 128, -128, -129, 1 << 20_000, -(1 << 20_000)):
|
||||
with self.subTest(bits=value.bit_length()):
|
||||
self.assertEqual(value, decode_worker_payload(encode_worker_payload(value)))
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
decode_worker_payload(b"GWP\x01\x07\x00\x00\x00\x07invalid")
|
||||
with self.assertRaises(WorkerPayloadError):
|
||||
encode_worker_payload(1 << 20_000, max_bytes=100)
|
||||
@@ -1,15 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.background import BackgroundTask
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from govoplan_core.auth import get_api_principal
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
||||
|
||||
|
||||
class ConditionalBufferTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_unknown_length_overflow_replays_exact_bytes_without_eager_drain(self) -> None:
|
||||
chunks = [b'{"value":"', b'', b'a' * 32, b'b' * 32, b'c' * 32, b'"}']
|
||||
consumed = []
|
||||
|
||||
async def body():
|
||||
for chunk in chunks:
|
||||
consumed.append(chunk)
|
||||
yield chunk
|
||||
|
||||
background = BackgroundTask(lambda: None)
|
||||
response = StreamingResponse(body(), media_type="application/json", background=background)
|
||||
|
||||
async def route(request):
|
||||
return response
|
||||
|
||||
request = Request({"type": "http", "method": "GET", "headers": [(b'if-none-match', b'*')]})
|
||||
with patch("govoplan_core.server.conditional_requests.MAX_CONDITIONAL_JSON_BYTES", 64, create=True):
|
||||
result = await conditional_json_get_middleware(request, route)
|
||||
self.assertIs(result, response)
|
||||
self.assertEqual(4, len(consumed))
|
||||
self.assertEqual(200, result.status_code)
|
||||
self.assertNotIn("etag", result.headers)
|
||||
self.assertIn("private", result.headers["cache-control"])
|
||||
self.assertIn("Authorization", result.headers["vary"])
|
||||
self.assertIs(background, result.background)
|
||||
self.assertEqual(b"".join(chunks), b"".join([chunk async for chunk in result.body_iterator]))
|
||||
self.assertEqual(chunks, consumed)
|
||||
|
||||
async def test_known_large_body_is_not_consumed(self) -> None:
|
||||
consumed = []
|
||||
|
||||
async def body():
|
||||
consumed.append(True)
|
||||
yield b"x" * 65
|
||||
|
||||
response = StreamingResponse(body(), media_type="application/json", headers={"Content-Length": "65"})
|
||||
|
||||
async def route(request):
|
||||
return response
|
||||
|
||||
request = Request({"type": "http", "method": "GET", "headers": []})
|
||||
with patch("govoplan_core.server.conditional_requests.MAX_CONDITIONAL_JSON_BYTES", 64, create=True):
|
||||
result = await conditional_json_get_middleware(request, route)
|
||||
self.assertIs(result, response)
|
||||
self.assertEqual([], consumed)
|
||||
self.assertEqual("65", result.headers["content-length"])
|
||||
self.assertEqual(b"x" * 65, b"".join([chunk async for chunk in result.body_iterator]))
|
||||
|
||||
async def test_matching_small_response_still_runs_current_route_authorization(self) -> None:
|
||||
calls = []
|
||||
|
||||
async def route(request):
|
||||
calls.append(True)
|
||||
if len(calls) > 1:
|
||||
return Response(status_code=403)
|
||||
return StreamingResponse(iter([b'{"ok":true}']), media_type="application/json")
|
||||
|
||||
request = Request({"type": "http", "method": "GET", "headers": []})
|
||||
first = await conditional_json_get_middleware(request, route)
|
||||
conditional = Request({"type": "http", "method": "GET", "headers": [(b'if-none-match', first.headers['etag'].encode())]})
|
||||
second = await conditional_json_get_middleware(conditional, route)
|
||||
self.assertEqual(403, second.status_code)
|
||||
self.assertEqual(2, len(calls))
|
||||
|
||||
|
||||
class ConditionalRequestTests(unittest.TestCase):
|
||||
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import JSON, Column, Integer, MetaData, Table, create_engine, select
|
||||
from sqlalchemy.dialects import mysql, postgresql
|
||||
from sqlalchemy.exc import CompileError
|
||||
|
||||
from govoplan_core.db.json_predicates import (
|
||||
json_array_contains_object_strings,
|
||||
json_array_contains_string,
|
||||
json_object_matches_strings,
|
||||
)
|
||||
|
||||
|
||||
class JsonPredicateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
self.metadata = MetaData()
|
||||
self.records = Table("records", self.metadata, Column("id", Integer, primary_key=True), Column("value", JSON))
|
||||
self.metadata.create_all(self.engine)
|
||||
|
||||
def tearDown(self):
|
||||
self.engine.dispose()
|
||||
|
||||
def matched(self, values, predicate):
|
||||
with self.engine.begin() as connection:
|
||||
connection.execute(self.records.insert(), [{"id": index, "value": value} for index, value in enumerate(values)])
|
||||
return list(connection.scalars(select(self.records.c.id).where(predicate).order_by(self.records.c.id)))
|
||||
|
||||
def test_string_membership_is_array_and_type_exact(self):
|
||||
self.assertEqual([0], self.matched(
|
||||
[["1"], [1], [True], [None], {"key": "1"}, "1", None, ["11"]],
|
||||
json_array_contains_string(self.records.c.value, "1"),
|
||||
))
|
||||
|
||||
def test_object_fields_are_exact_strings_not_coerced_or_substrings(self):
|
||||
self.assertEqual([0], self.matched(
|
||||
[{"kind": "account", "id": "1", "label": "Extra field allowed"},
|
||||
{"kind": "account", "id": 1}, {"kind": "account", "id": "11"},
|
||||
"not-json", None, ["account", "1"]],
|
||||
json_object_matches_strings(self.records.c.value, {"kind": "account", "id": "1"}),
|
||||
))
|
||||
|
||||
def test_array_objects_do_not_match_encoded_objects_or_scalar_elements(self):
|
||||
self.assertEqual([0], self.matched(
|
||||
[[{"kind": "account", "id": "1"}], ['{"kind":"account","id":"1"}'],
|
||||
[{"kind": "account", "id": 1}], ["not-json", None, True, 1],
|
||||
{"kind": "account", "id": "1"}, None],
|
||||
json_array_contains_object_strings(self.records.c.value, {"kind": "account", "id": "1"}),
|
||||
))
|
||||
|
||||
def test_values_remain_bound_for_both_dialects(self):
|
||||
value = "x' OR 1=1 --"
|
||||
predicates = [
|
||||
json_array_contains_string(self.records.c.value, value),
|
||||
json_object_matches_strings(self.records.c.value, {"id": value}),
|
||||
json_array_contains_object_strings(self.records.c.value, {"id": value}),
|
||||
]
|
||||
for predicate in predicates:
|
||||
for dialect in (self.engine.dialect, postgresql.dialect()):
|
||||
with self.subTest(predicate=type(predicate).__name__, dialect=dialect.name):
|
||||
compiled = select(self.records.c.id).where(predicate).compile(dialect=dialect)
|
||||
self.assertNotIn(value, str(compiled))
|
||||
self.assertIn(value, compiled.params.values())
|
||||
with self.assertRaises(CompileError):
|
||||
select(self.records.c.id).where(predicate).compile(dialect=mysql.dialect())
|
||||
|
||||
def test_invalid_field_names_and_non_string_matches_are_rejected(self):
|
||||
for fields in ({"id": 1}, {"not.a.field": "1"}, {}):
|
||||
with self.subTest(fields=fields), self.assertRaises((TypeError, ValueError)):
|
||||
json_array_contains_object_strings(self.records.c.value, fields)
|
||||
with self.assertRaises(TypeError):
|
||||
json_array_contains_string(self.records.c.value, True)
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from alembic import context
|
||||
from alembic.config import Config
|
||||
|
||||
|
||||
class MigrationUrlConfigurationTests(unittest.TestCase):
|
||||
def test_database_urls_round_trip_exactly_in_online_and_offline_modes(self) -> None:
|
||||
urls = (
|
||||
"postgresql+psycopg://localhost/example?host=%2Ftmp%2Fexample",
|
||||
"postgresql+psycopg://synthetic%40user:synthetic%25%40pass@localhost/example",
|
||||
"sqlite:////tmp/synthetic%25-database.db",
|
||||
"sqlite:////tmp/synthetic%%-database.db",
|
||||
"sqlite:////tmp/%(here)s-literal.db",
|
||||
"sqlite:////tmp/synthetic-database.db",
|
||||
)
|
||||
environment = Path(__file__).resolve().parents[1] / "alembic" / "env.py"
|
||||
for url in urls:
|
||||
for offline in (False, True):
|
||||
for from_settings in (False, True):
|
||||
with self.subTest(url=url, offline=offline, from_settings=from_settings):
|
||||
config = Config()
|
||||
config.attributes.update(enabled_modules=(), manifest_factories=())
|
||||
if not from_settings:
|
||||
config.attributes["database_url"] = url
|
||||
engine = MagicMock()
|
||||
connection = engine.connect.return_value.__enter__.return_value
|
||||
with (
|
||||
patch.object(context, "config", config, create=True),
|
||||
patch.object(context, "is_offline_mode", return_value=offline),
|
||||
patch.object(context, "configure") as configure,
|
||||
patch.object(context, "begin_transaction"),
|
||||
patch.object(context, "run_migrations") as run_migrations,
|
||||
patch("sqlalchemy.engine_from_config", return_value=engine) as engine_from_config,
|
||||
patch(
|
||||
"govoplan_core.server.default_config.get_server_config",
|
||||
return_value=SimpleNamespace(enabled_modules=(), manifest_factories=()),
|
||||
),
|
||||
patch("govoplan_core.server.registry.build_platform_registry"),
|
||||
patch(
|
||||
"govoplan_core.core.migrations.migration_metadata_plan",
|
||||
return_value=SimpleNamespace(metadata=()),
|
||||
),
|
||||
patch("govoplan_core.settings.settings.database_url", url),
|
||||
):
|
||||
runpy.run_path(str(environment))
|
||||
|
||||
self.assertEqual(config.get_main_option("sqlalchemy.url"), url)
|
||||
self.assertEqual(config.get_section(config.config_ini_section)["sqlalchemy.url"], url)
|
||||
run_migrations.assert_called_once_with()
|
||||
if offline:
|
||||
engine_from_config.assert_not_called()
|
||||
self.assertEqual(configure.call_args.kwargs["url"], url)
|
||||
else:
|
||||
engine_from_config.assert_called_once()
|
||||
self.assertEqual(engine_from_config.call_args.args[0]["sqlalchemy.url"], url)
|
||||
self.assertIs(configure.call_args.kwargs["connection"], connection)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -27,6 +27,25 @@ class _RevisionFixture(Base):
|
||||
|
||||
|
||||
class OptimisticConcurrencyTests(unittest.TestCase):
|
||||
def test_keyed_insertions_keep_their_anchors_during_disjoint_edits(self) -> None:
|
||||
base = [{"id": "a", "value": 1}, {"id": "b", "value": 2}]
|
||||
local = [base[0], {"id": "x", "value": 3}, base[1]]
|
||||
current = [base[0], {"id": "b", "value": 20}]
|
||||
result = three_way_merge(base, local, current)
|
||||
self.assertTrue(result.merged)
|
||||
self.assertEqual(["a", "x", "b"], [item["id"] for item in result.value])
|
||||
self.assertEqual(20, result.value[-1]["value"])
|
||||
self.assertEqual(["a", "b"], [item["id"] for item in base])
|
||||
|
||||
def test_concurrent_insertions_are_stable_and_incompatible_anchors_conflict(self) -> None:
|
||||
base = [{"id": "a"}, {"id": "b"}]
|
||||
result = three_way_merge(base, [base[0], {"id": "x"}, base[1]], [base[0], {"id": "y"}, base[1]])
|
||||
self.assertTrue(result.merged)
|
||||
self.assertEqual(["a", "y", "x", "b"], [item["id"] for item in result.value])
|
||||
conflict = three_way_merge(base, [base[0], {"id": "x"}, base[1]], [base[1], base[0]])
|
||||
self.assertFalse(conflict.merged)
|
||||
self.assertEqual("collection_reorder", conflict.conflicts[0].kind)
|
||||
|
||||
def test_strong_etags_and_if_match_use_strong_comparison(self) -> None:
|
||||
etag = strong_resource_etag("campaign_version", "version-1", 3)
|
||||
|
||||
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.principal_helpers import principal_actor_ids, principal_user_first_actor
|
||||
|
||||
|
||||
class PrincipalHelperTests(unittest.TestCase):
|
||||
def test_contracts_keep_their_distinct_precedence_and_whitespace(self) -> None:
|
||||
principal = SimpleNamespace(account_id=" account ", identity_id=" identity ", membership_id=" membership ", user=SimpleNamespace(id=" user "))
|
||||
self.assertEqual((" account ", " identity ", " membership ", " user "), principal_actor_ids(principal))
|
||||
self.assertEqual("user", principal_user_first_actor(principal))
|
||||
self.assertEqual(" account ", principal.account_id)
|
||||
self.assertEqual(" user ", principal.user.id)
|
||||
|
||||
def test_duplicate_ids_are_removed_in_stable_order_without_normalizing(self) -> None:
|
||||
principal = SimpleNamespace(account_id="same", identity_id=" same ", membership_id="same", user=SimpleNamespace(id="same"))
|
||||
self.assertEqual(("same", " same "), principal_actor_ids(principal))
|
||||
self.assertEqual("same", principal_user_first_actor(principal))
|
||||
|
||||
def test_missing_blank_and_service_only_principals_remain_unattributed(self) -> None:
|
||||
for principal in (
|
||||
object(), None, SimpleNamespace(), SimpleNamespace(service_account_id="service"),
|
||||
SimpleNamespace(account_id=0, identity_id=False, membership_id="\t", user=SimpleNamespace(id="\u00a0")),
|
||||
):
|
||||
with self.subTest(principal=principal):
|
||||
self.assertEqual((), principal_actor_ids(principal))
|
||||
self.assertIsNone(principal_user_first_actor(principal))
|
||||
|
||||
def test_fallback_and_existing_string_coercion_are_unchanged(self) -> None:
|
||||
for values, expected_ids, expected_actor in (
|
||||
({"account_id": 17, "identity_id": "identity"}, ("17", "identity"), "17"),
|
||||
({"identity_id": " identity ", "membership_id": "member"}, (" identity ", "member"), "identity"),
|
||||
({"membership_id": " member "}, (" member ",), "member"),
|
||||
({"user": SimpleNamespace(id=" user ")}, (" user ",), "user"),
|
||||
):
|
||||
with self.subTest(values=values):
|
||||
principal = SimpleNamespace(**values)
|
||||
self.assertEqual(expected_ids, principal_actor_ids(principal))
|
||||
self.assertEqual(expected_actor, principal_user_first_actor(principal))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import unittest
|
||||
from decimal import Decimal
|
||||
|
||||
from govoplan_core.core.modules import DocumentationTopic, localize_documentation_topics
|
||||
from govoplan_core.core.tabular_sources import (
|
||||
TabularColumn,
|
||||
TabularCsvSource,
|
||||
TabularSourceUnavailableError,
|
||||
TabularSourceValidationError,
|
||||
csv_source_payload,
|
||||
csv_source_summary,
|
||||
verified_csv_source_text,
|
||||
csv_projection_matches,
|
||||
infer_tabular_schema,
|
||||
parse_tabular_csv,
|
||||
tabular_type_name,
|
||||
)
|
||||
|
||||
|
||||
class SharedReviewMechanicsTests(unittest.TestCase):
|
||||
def test_invalid_csv_unicode_fails_explicitly_without_echoing_content(self) -> None:
|
||||
source = TabularCsvSource(text="value\nprivate-\ud800\n")
|
||||
for action in (
|
||||
lambda: csv_source_payload(source),
|
||||
lambda: parse_tabular_csv(source.text),
|
||||
):
|
||||
with (
|
||||
self.subTest(action=action),
|
||||
self.assertRaises(TabularSourceValidationError) as raised,
|
||||
):
|
||||
action()
|
||||
self.assertNotIn("private", str(raised.exception))
|
||||
with self.assertRaises(TabularSourceUnavailableError):
|
||||
verified_csv_source_text({"text": source.text})
|
||||
|
||||
def test_csv_huge_integer_has_explicit_validation_and_lossless_text_option(
|
||||
self,
|
||||
) -> None:
|
||||
import sys
|
||||
|
||||
maximum = sys.get_int_max_str_digits()
|
||||
if not maximum:
|
||||
self.skipTest("Interpreter integer conversion limit is disabled")
|
||||
text = "9" * (maximum + 1)
|
||||
with self.assertRaisesRegex(TabularSourceValidationError, "use text mode"):
|
||||
parse_tabular_csv("value\n" + text + "\n")
|
||||
self.assertEqual(
|
||||
({"value": text},),
|
||||
parse_tabular_csv("value\n" + text + "\n", value_mode="text"),
|
||||
)
|
||||
|
||||
def test_csv_projection_binding_distinguishes_boolean_integer_and_float(
|
||||
self,
|
||||
) -> None:
|
||||
for expected in (True, 1, 1.0):
|
||||
for actual in (True, 1, 1.0):
|
||||
with self.subTest(
|
||||
expected_type=type(expected), actual_type=type(actual)
|
||||
):
|
||||
self.assertEqual(
|
||||
type(expected) is type(actual),
|
||||
csv_projection_matches(
|
||||
({"value": expected},), ({"value": actual},)
|
||||
),
|
||||
)
|
||||
self.assertFalse(csv_projection_matches(({"value": 1},), ({"other": 1},)))
|
||||
|
||||
def test_translation_merge_preserves_owner_data_and_untranslated_identity(
|
||||
self,
|
||||
) -> None:
|
||||
translations = {"de": {"summary": "vorhanden"}, "fr": {"title": "Français"}}
|
||||
first = DocumentationTopic(
|
||||
id="one", title="One", summary="First", translations=translations
|
||||
)
|
||||
unchanged = DocumentationTopic(id="two", title="Two", summary="Second")
|
||||
localized = localize_documentation_topics(
|
||||
iter((first, unchanged)),
|
||||
locale="de",
|
||||
translations={"one": {"title": "Eins"}},
|
||||
)
|
||||
self.assertEqual(
|
||||
{"title": "Eins", "summary": "vorhanden"}, localized[0].translations["de"]
|
||||
)
|
||||
self.assertEqual({"title": "Français"}, localized[0].translations["fr"])
|
||||
self.assertIs(unchanged, localized[1])
|
||||
self.assertEqual({"summary": "vorhanden"}, first.translations["de"])
|
||||
self.assertIsNot(translations["fr"], localized[0].translations["fr"])
|
||||
|
||||
def test_schema_inference_matches_legacy_projection_for_sparse_mixed_rows(
|
||||
self,
|
||||
) -> None:
|
||||
generator = random.Random(298)
|
||||
values = [None, True, False, 1, 1.5, Decimal("1.00"), "001", [], {}]
|
||||
rows = [
|
||||
{
|
||||
f"field_{column}": generator.choice(values)
|
||||
for column in generator.sample(range(80), 20)
|
||||
}
|
||||
for _ in range(100)
|
||||
]
|
||||
names = list(dict.fromkeys(name for row in rows for name in row))
|
||||
expected = []
|
||||
for name in names:
|
||||
concrete = [row[name] for row in rows if row.get(name) is not None]
|
||||
types = {tabular_type_name(value) for value in concrete}
|
||||
kind = (
|
||||
"unknown"
|
||||
if not types
|
||||
else next(iter(types))
|
||||
if len(types) == 1
|
||||
else "mixed"
|
||||
)
|
||||
expected.append(
|
||||
TabularColumn(
|
||||
name=name, data_type=kind, nullable=len(concrete) != len(rows)
|
||||
)
|
||||
)
|
||||
self.assertEqual(tuple(expected), infer_tabular_schema(rows))
|
||||
self.assertEqual(
|
||||
(TabularColumn("only_null", "unknown", True),),
|
||||
infer_tabular_schema([{"only_null": None}]),
|
||||
)
|
||||
self.assertEqual((), infer_tabular_schema([]))
|
||||
unknown = type("ẞ", (), {})()
|
||||
self.assertEqual("ß", tabular_type_name(unknown))
|
||||
self.assertEqual("ss", tabular_type_name(unknown, casefold_unknown=True))
|
||||
|
||||
def test_csv_evidence_keeps_exact_text_but_summary_never_contains_content(
|
||||
self,
|
||||
) -> None:
|
||||
source = TabularCsvSource(
|
||||
text='\ufeffvalue\r\n" text "\r\n', value_mode="text"
|
||||
)
|
||||
payload = csv_source_payload(source)
|
||||
self.assertEqual(source.text, verified_csv_source_text(payload))
|
||||
self.assertNotIn("text", csv_source_summary(payload))
|
||||
self.assertEqual(len(source.text.encode("utf-8")), payload["byte_count"])
|
||||
with self.assertRaises(TabularSourceUnavailableError):
|
||||
verified_csv_source_text(
|
||||
{**payload, "text": source.text.replace("text", "edited")}
|
||||
)
|
||||
@@ -178,6 +178,29 @@ class TabularSourceContractTests(unittest.TestCase):
|
||||
with self.assertRaises(TabularSourceValidationError):
|
||||
parse_tabular_csv("id,name\n1,Ada,extra\n")
|
||||
|
||||
def test_text_csv_mode_preserves_lexical_values_and_explicit_empty_records(self) -> None:
|
||||
source = 'id,value\r\n9007199254740993," keep me "\r\ntrue,0.123456789012345678901234567890\r\n" ",""\r\n'
|
||||
self.assertEqual(
|
||||
(
|
||||
{"id": "9007199254740993", "value": " keep me "},
|
||||
{"id": "true", "value": "0.123456789012345678901234567890"},
|
||||
{"id": " ", "value": ""},
|
||||
),
|
||||
parse_tabular_csv(source, value_mode="text"),
|
||||
)
|
||||
self.assertEqual(({"value": " "},), parse_tabular_csv('value\n" "\n', value_mode="text"))
|
||||
|
||||
def test_text_csv_mode_rejects_shape_loss_and_applies_input_and_row_bounds(self) -> None:
|
||||
for source in ('id,name\n1\n', 'id,name\n1,Ada,\n'):
|
||||
with self.subTest(source=source), self.assertRaises(TabularSourceValidationError):
|
||||
parse_tabular_csv(source, value_mode="text")
|
||||
with self.assertRaises(TabularSourceValidationError):
|
||||
parse_tabular_csv('value\n""\n""\n', value_mode="text", max_rows=1)
|
||||
with self.assertRaises(TabularSourceValidationError):
|
||||
parse_tabular_csv('value\nä\n', value_mode="text", max_bytes=8)
|
||||
with self.assertRaises(TabularSourceValidationError):
|
||||
parse_tabular_csv('value\nx\n', value_mode="unknown")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import AttachmentsDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage";
|
||||
import RecipientDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/RecipientDataPage";
|
||||
import { generatedTranslations as campaignTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import ManagedFileChooser from "../../../govoplan-files/webui/src/features/files/components/ManagedFileChooser";
|
||||
import { listFileSpaces } from "../../../govoplan-files/webui/src/api/files";
|
||||
@@ -27,7 +28,9 @@ export default function CampaignAttachmentsScenario() {
|
||||
<PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[campaignTranslations, filesTranslations]}>
|
||||
<button type="button" onClick={() => setAvailable(value => !value)}>Toggle Files capability</button>
|
||||
<ConcurrencyConflictProvider>
|
||||
<AttachmentsDataPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="campaign-attachments" />
|
||||
{new URLSearchParams(window.location.search).has("recipient-data")
|
||||
? <RecipientDataPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} campaignId="campaign-attachments" />
|
||||
: <AttachmentsDataPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="campaign-attachments" />}
|
||||
</ConcurrencyConflictProvider>
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { useState } from "react";
|
||||
import { Eye } from "lucide-react";
|
||||
import Button from "../src/components/Button";
|
||||
import Card from "../src/components/Card";
|
||||
import ConnectionTree from "../src/components/ConnectionTree";
|
||||
import ContentSection from "../src/components/ContentSection";
|
||||
import LoadingFrame from "../src/components/LoadingFrame";
|
||||
import DataGrid, { type DataGridColumn } from "../src/components/table/DataGrid";
|
||||
import TableActionGroup from "../src/components/table/TableActionGroup";
|
||||
|
||||
type Row = { id: string; name: string; detail: string };
|
||||
const rows: Row[] = ["Alpha", "Beta", "Gamma"].map((name) => ({ id: name, name, detail: "A long configured value ".repeat(12) }));
|
||||
|
||||
/** The real shared surfaces, without module-local inset or width overrides. */
|
||||
export default function CardTableLayoutScenario() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inspected, setInspected] = useState("");
|
||||
const columns: DataGridColumn<Row>[] = [
|
||||
{ id: "name", header: "Name", minWidth: 160, value: (row) => row.name },
|
||||
{ id: "detail", header: "Details", minWidth: 220, value: (row) => row.detail },
|
||||
{ id: "actions", header: "Actions", columnType: "actions", sticky: "end", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: `Inspect ${row.name}`, icon: <Eye />, onClick: () => setInspected(row.name) }
|
||||
]} /> }
|
||||
];
|
||||
const grid = (id: string) => <DataGrid id={`card-table-${id}`} rows={rows} columns={columns} getRowKey={(row) => row.id} />;
|
||||
const tree = () => <ConnectionTree rows={rows} columns={[{ id: "name", header: "Name", render: (row: Row) => row.name }]} getRowKey={(row) => row.id} />;
|
||||
return <main style={{ padding: 16, minWidth: 0, display: "grid", gap: 16, gridTemplateColumns: "minmax(0, 1fr)" }}>
|
||||
<Button onClick={() => setLoading((value) => !value)}>Toggle loading</Button>
|
||||
<output data-testid="card-table-inspected">{inspected}</output>
|
||||
<Card title="Direct table" data-testid="direct-table">{grid("direct")}</Card>
|
||||
<Card title="Collapsible table" data-testid="collapsible-table" collapsible persistCollapse={false}>{grid("collapsible")}</Card>
|
||||
<Card title="Loading table" data-testid="loading-table"><LoadingFrame loading={loading} indicator="none" label="Loading rows">{grid("loading")}</LoadingFrame></Card>
|
||||
<Card title="Admin table" data-testid="admin-table"><div className="admin-table-surface">{grid("admin")}</div></Card>
|
||||
<Card title="Loading admin table" data-testid="loading-admin-table"><LoadingFrame loading={loading} indicator="none" label="Loading rows"><div className="admin-table-surface">{grid("loading-admin")}</div></LoadingFrame></Card>
|
||||
<Card title="Connection table" data-testid="connection-table">{tree()}</Card>
|
||||
<Card title="Loading connection table" data-testid="loading-connection-table"><LoadingFrame loading={loading} indicator="none" label="Loading rows">{tree()}</LoadingFrame></Card>
|
||||
<Card title="Explicit table" data-testid="explicit-table" bodyLayout="table"><LoadingFrame loading={loading} indicator="none" label="Loading rows">{grid("explicit")}</LoadingFrame></Card>
|
||||
<Card title="Table with context" data-testid="table-with-context" bodyLayout="table">
|
||||
<ContentSection density="default" spacing="none">Meaningful table context</ContentSection>
|
||||
{grid("context")}
|
||||
</Card>
|
||||
<Card title="Mixed content" data-testid="mixed-content"><p>Ordinary content keeps its inset.</p>{grid("mixed")}</Card>
|
||||
<Card title="Loading mixed content" data-testid="loading-mixed-content"><LoadingFrame loading={loading} indicator="none" label="Loading rows"><p>Ordinary content keeps its inset.</p>{grid("loading-mixed")}</LoadingFrame></Card>
|
||||
<div data-testid="standalone-table">{grid("standalone")}</div>
|
||||
</main>;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import DialogLayoutScenario from "./DialogLayoutScenario";
|
||||
import HeadingHelpScenario from "./HeadingHelpScenario";
|
||||
import DataGridLayoutScenario from "./DataGridLayoutScenario";
|
||||
import NavigationLayoutScenario from "./NavigationLayoutScenario";
|
||||
import ManagedArchiveScenario from "./ManagedArchiveScenario";
|
||||
@@ -11,6 +12,7 @@ import FormControlLayoutScenario from "./FormControlLayoutScenario";
|
||||
import CampaignWorkspaceScenario from "./CampaignWorkspaceScenario";
|
||||
import CampaignReportScenario from "./CampaignReportScenario";
|
||||
import ModuleLayoutScenario from "./ModuleLayoutScenario";
|
||||
import DashboardConfigurationScenario from "./DashboardConfigurationScenario";
|
||||
import HelpCenterScenario from "./HelpCenterScenario";
|
||||
import NotificationFilterScenario from "./NotificationFilterScenario";
|
||||
import MultiSelectFilterScenario from "./MultiSelectFilterScenario";
|
||||
@@ -28,6 +30,7 @@ import CampaignBulkReviewScenario from "./CampaignBulkReviewScenario";
|
||||
import CampaignReviewDetailsScenario from "./CampaignReviewDetailsScenario";
|
||||
import CampaignDeliveryPolicyScenario from "./CampaignDeliveryPolicyScenario";
|
||||
import MailCredentialPolicyScenario from "./MailCredentialPolicyScenario";
|
||||
import PasswordLifecycleScenario from "./PasswordLifecycleScenario";
|
||||
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
|
||||
import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
|
||||
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
|
||||
@@ -83,12 +86,17 @@ export default function ConformanceApp() {
|
||||
const [editorDirty, setEditorDirty] = useState(true);
|
||||
const [metricDrilldown, setMetricDrilldown] = useState("");
|
||||
|
||||
if (new URLSearchParams(location.search).has("heading-help")) return <HeadingHelpScenario />;
|
||||
|
||||
if (new URLSearchParams(location.search).has("password-lifecycle")) return <PasswordLifecycleScenario />;
|
||||
|
||||
if (new URLSearchParams(location.search).has("credential-references")) return <CredentialReferencesScenario />;
|
||||
if (new URLSearchParams(location.search).has("files-toolbar")) return <FilesToolbarScenario />;
|
||||
if (new URLSearchParams(location.search).has("form-control-layout")) return <FormControlLayoutScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-workspace")) return <CampaignWorkspaceScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-report")) return <CampaignReportScenario />;
|
||||
if (new URLSearchParams(location.search).has("module-layouts")) return <ModuleLayoutScenario />;
|
||||
if (new URLSearchParams(location.search).has("dashboard-configuration")) return <DashboardConfigurationScenario />;
|
||||
if (new URLSearchParams(location.search).has("help-center")) return <HelpCenterScenario />;
|
||||
if (new URLSearchParams(location.search).has("notification-filter")) return <NotificationFilterScenario />;
|
||||
if (new URLSearchParams(location.search).has("multi-select-filter")) return <MultiSelectFilterScenario />;
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import DashboardPage from "../../../govoplan-dashboard/webui/src/features/dashboard/DashboardPage";
|
||||
import { dashboardModule } from "../../../govoplan-dashboard/webui/src/module";
|
||||
import { generatedTranslations } from "../../../govoplan-dashboard/webui/src/i18n/generatedTranslations";
|
||||
import { schedulingModule } from "../../../govoplan-scheduling/webui/src/module";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "dashboard-user", account_id: "dashboard-account", email: "dashboard@example.test" },
|
||||
tenant: { id: "dashboard-tenant", name: "Dashboard fixture", slug: "dashboard" },
|
||||
scopes: ["dashboard:dashboard:read"], roles: [], groups: [],
|
||||
profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
|
||||
export default function DashboardConfigurationScenario() {
|
||||
const widgetHelp = new URLSearchParams(location.search).has("widget-help");
|
||||
const modules = widgetHelp ? [dashboardModule, schedulingModule] : [dashboardModule];
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider
|
||||
preferredLanguageCode={new URLSearchParams(location.search).get("language") ?? "en"}
|
||||
moduleTranslations={[generatedTranslations, ...(widgetHelp ? [schedulingModule.translations] : [])]}>
|
||||
<DashboardPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={widgetHelp ? { ...auth, scopes: [...auth.scopes, "scheduling:schedule:read"] } : auth} />
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -3,13 +3,21 @@ import { ArrowDown, ArrowUp, Eye, Plus, Trash2 } from "lucide-react";
|
||||
import Button from "../src/components/Button";
|
||||
import DataGrid, { DataGridEmptyAction, type DataGridColumn, type DataGridResizeBehavior } from "../src/components/table/DataGrid";
|
||||
import TableActionGroup from "../src/components/table/TableActionGroup";
|
||||
import CardTableLayoutScenario from "./CardTableLayoutScenario";
|
||||
|
||||
type Row = { id: string; name: string; detail: string };
|
||||
const rows: Row[] = [{ id: "alpha", name: "Alpha", detail: "Long configured field value ".repeat(20) }];
|
||||
|
||||
/** Genuine shared grid: intentionally undersized legacy action preference. */
|
||||
export default function DataGridLayoutScenario() {
|
||||
if (new URLSearchParams(window.location.search).has("table-cards")) return <CardTableLayoutScenario />;
|
||||
return <ResizableDataGridScenario />;
|
||||
}
|
||||
|
||||
function ResizableDataGridScenario() {
|
||||
const mode = new URLSearchParams(window.location.search).get("mode") ?? "cover";
|
||||
const mixedColumns = new URLSearchParams(window.location.search).has("mixed-columns");
|
||||
const preferredLimits = new URLSearchParams(window.location.search).has("preferred-limits");
|
||||
const behavior: DataGridResizeBehavior = mode === "free" || mode === "constrained" ? mode : "cover";
|
||||
const composite = mode === "composite";
|
||||
const [width, setWidth] = useState(900);
|
||||
@@ -18,8 +26,9 @@ export default function DataGridLayoutScenario() {
|
||||
const [extraAction, setExtraAction] = useState(false);
|
||||
const [clicked, setClicked] = useState("");
|
||||
const columns = useMemo<DataGridColumn<Row>[]>(() => [
|
||||
{ id: "name", header: "Name", width: 260, minWidth: 180, resizable: true, value: (row) => row.name },
|
||||
{ id: "detail", header: "Details", width: 360, minWidth: 220, resizable: true, value: (row) => row.detail },
|
||||
{ id: "name", header: "Name", width: 260, minWidth: 180, preferredMaxWidth: preferredLimits ? 300 : undefined, resizable: true, value: (row) => row.name },
|
||||
...(mixedColumns ? [{ id: "fixed", header: "Fixed", width: 144, minWidth: 144, maxWidth: 144, value: () => "Fixed value" }] : []),
|
||||
{ id: "detail", header: "Details", width: 360, minWidth: 220, preferredMaxWidth: preferredLimits ? 360 : undefined, resizable: true, value: (row) => row.detail },
|
||||
{
|
||||
id: "actions", header: "Actions", width: mode === "oversized" ? 500 : 72,
|
||||
minWidth: mode === "oversized" ? 500 : undefined,
|
||||
@@ -38,7 +47,7 @@ export default function DataGridLayoutScenario() {
|
||||
</div> : group;
|
||||
}
|
||||
}
|
||||
], [behavior, composite, extraAction, mode]);
|
||||
], [behavior, composite, extraAction, mixedColumns, mode, preferredLimits]);
|
||||
return (
|
||||
<main style={{ padding: 16, minWidth: 0 }}>
|
||||
<h1>Data grid layout conformance</h1>
|
||||
@@ -52,7 +61,7 @@ export default function DataGridLayoutScenario() {
|
||||
<output data-testid="clicked-action">{clicked}</output>
|
||||
<div data-testid="grid-container" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", width, maxWidth: "100%", minWidth: 0 }}>
|
||||
{mounted && <DataGrid
|
||||
id={`layout-conformance-${behavior}`}
|
||||
id={`layout-conformance-${behavior}${mixedColumns ? "-mixed" : ""}`}
|
||||
rows={empty ? [] : rows}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.id}
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import Button from "../src/components/Button";
|
||||
import Card from "../src/components/Card";
|
||||
import Dialog from "../src/components/Dialog";
|
||||
import PageLayout from "../src/components/PageLayout";
|
||||
import PageActionBar from "../src/components/PageActionBar";
|
||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||
import DocumentationHelpLink, { DocumentationHelpProvider } from "../src/components/help/DocumentationHelpLink";
|
||||
import TextWithHelp from "../src/components/help/TextWithHelp";
|
||||
import WidgetHeadingHelpScenario from "./WidgetHeadingHelpScenario";
|
||||
|
||||
export default function HeadingHelpScenario() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
if (new URLSearchParams(location.search).has("widgets")) return <WidgetHeadingHelpScenario />;
|
||||
const help = <DocumentationHelpLink reference={{ contextId: "dashboard" }} />;
|
||||
const longTitle = `Dashboard ${"DokumentationsüberschriftOhneLeerzeichen".repeat(5)}`;
|
||||
return <DocumentationHelpProvider localDocsAvailable={false}>
|
||||
<main className="conformance-root">
|
||||
<PageLayout archetype="overview" mode="embedded" title="Dashboard" titleHelp={help} headerLoading={loading}
|
||||
actions={<PageActionBar variant="overview" primaryActions={<Button onClick={() => setLoading(value => !value)}>Toggle loading</Button>} />}>
|
||||
<WorkspaceActionBar variant="workspace" title="Workspace" titleHelp={help} primaryActions={<Button>Refresh</Button>} />
|
||||
<Card title={longTitle} titleHelp={help} collapsible persistCollapse={false} data-testid="help-card">
|
||||
<p data-testid="help-card-content">Saved data</p>
|
||||
</Card>
|
||||
<TextWithHelp as="div" help={help}><h3>Section</h3></TextWithHelp>
|
||||
<div style={{ width: 120 }} data-testid="raw-text-help">
|
||||
<TextWithHelp help={help}>{"UnbrokenLabel".repeat(8)}</TextWithHelp>
|
||||
</div>
|
||||
<Button data-testid="open-help-dialog" onClick={() => setOpen(true)}>Edit settings</Button>
|
||||
<Dialog open={open} title={longTitle} titleHelp={help} onClose={() => setOpen(false)}
|
||||
footer={<Button data-testid="dialog-last-action">Save</Button>}>
|
||||
<label>Value<input defaultValue="Saved value" /></label>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
</main>
|
||||
</DocumentationHelpProvider>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import "../src/styles/auth-gate.css";
|
||||
import { useLocation } from "react-router";
|
||||
import PasswordChangePanel from "../../../govoplan-access/webui/src/features/passwords/PasswordChangePanel";
|
||||
import PasswordRecoveryPage from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryPage";
|
||||
import PasswordRecoveryIssueDialog from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryIssueDialog";
|
||||
import PasswordLoginHelp from "../../../govoplan-access/webui/src/features/passwords/PasswordLoginHelp";
|
||||
import SystemUsersPanel from "../../../govoplan-access/webui/src/features/admin/SystemUsersPanel";
|
||||
import { passwordTranslations } from "../../../govoplan-access/webui/src/i18n/passwordTranslations";
|
||||
import { generatedTranslations } from "../../../govoplan-access/webui/src/i18n/generatedTranslations";
|
||||
import AuthActionGate from "../src/features/auth/AuthActionGate";
|
||||
import LoginModal from "../src/features/auth/LoginModal";
|
||||
import Button from "../src/components/Button";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { ApiSettings, AuthActionUiCapability, AuthInfo, AuthUpdate, PlatformWebModule } from "../src/types";
|
||||
|
||||
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
const capability: AuthActionUiCapability = {
|
||||
actions: ["change_password"], RequiredAction: PasswordChangePanel, LoginHelp: PasswordLoginHelp
|
||||
};
|
||||
const modules: PlatformWebModule[] = [{
|
||||
id: "access", label: "Access", version: "fixture", uiCapabilities: { "auth.actions": capability },
|
||||
helpContexts: [
|
||||
{ id: "access.password.change", topic_id: "access.help.password-change", title: "Change your local password", documentation_types: ["user", "admin"] },
|
||||
{ id: "access.password.recover", topic_id: "access.help.password-recovery", title: "Recover a local password", documentation_types: ["user", "admin"] },
|
||||
{ id: "access.password.issue-recovery", topic_id: "access.help.password-issue-recovery", title: "Issue and hand over a recovery code", documentation_types: ["user", "admin"] }
|
||||
]
|
||||
}];
|
||||
|
||||
export default function PasswordLifecycleScenario() {
|
||||
const parameters = new URLSearchParams(useLocation().search);
|
||||
const mode = parameters.get("mode") ?? "required";
|
||||
const language = parameters.get("language") ?? "en";
|
||||
const tenant = { id: "tenant-1", slug: "fixture", name: "Fixture" };
|
||||
const owner = parameters.get("owner") !== "false";
|
||||
const [auth, setAuth] = useState<AuthInfo>({
|
||||
user: { id: "membership-1", account_id: "account-1", email: "person@example.test", local_password: parameters.get("external") !== "true", required_auth_action: mode === "required" || mode === "missing" ? "change_password" : null },
|
||||
tenant, active_tenant: tenant, scopes: mode === "required" || mode === "missing" ? [] : owner ? ["system:*"] : ["system:accounts:update"], roles: [], groups: [],
|
||||
principal: { account_id: "account-1", membership_id: "membership-1", auth_method: parameters.get("api-key") ? "api_key" : "session", scopes: [], group_ids: [], session_id: "old-session" },
|
||||
profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
});
|
||||
const [updated, setUpdated] = useState("");
|
||||
const [open, setOpen] = useState(true);
|
||||
function update(next: AuthUpdate | null, token?: string) {
|
||||
setUpdated(JSON.stringify({ action: next?.user?.required_auth_action ?? null, token, session: next?.principal?.session_id }));
|
||||
if (next?.user) setAuth((current) => ({ ...current, ...next, user: { ...current.user, ...next.user }, tenant: current.tenant, active_tenant: current.active_tenant, tenants: current.tenants }));
|
||||
}
|
||||
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations, passwordTranslations]}>
|
||||
<PlatformModulesProvider modules={mode === "missing" ? [] : modules}>
|
||||
<div data-testid="password-scenario">
|
||||
{mode === "required" || mode === "missing"
|
||||
? auth.user.required_auth_action
|
||||
? <AuthActionGate settings={settings} auth={auth} capability={mode === "missing" ? null : capability} onAuthChange={update} onSignOut={() => setOpen(false)} />
|
||||
: <h1>Workspace available</h1>
|
||||
: mode === "recover" ? <PasswordRecoveryPage settings={settings} />
|
||||
: mode === "issue" ? open
|
||||
? <PasswordRecoveryIssueDialog settings={settings} account={{ account_id: "target-1", email: "target@example.test" }} onClose={() => setOpen(false)} />
|
||||
: <Button onClick={() => setOpen(true)}>Reopen recovery</Button>
|
||||
: mode === "admin" ? <SystemUsersPanel settings={settings} auth={auth} canCreate={false} canUpdate canSuspend={false} canAssignRoles={false} canManageMemberships={false} onAuthRefresh={async () => {}} />
|
||||
: mode === "login" ? open && <LoginModal settings={settings} onClose={() => setOpen(false)} onLogin={() => {}} />
|
||||
: <PasswordChangePanel settings={settings} auth={auth} onAuthChange={update} />}
|
||||
<output data-testid="auth-update">{updated}</output>
|
||||
</div>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -3,11 +3,16 @@
|
||||
// generated module catalogue into this isolated test bundle.
|
||||
export { ApiError, apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "../src/api/client";
|
||||
export { fetchAuthGroups } from "../src/api/auth";
|
||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "../src/api/adminCommon";
|
||||
export type { AdminOverview, PermissionItem, TenantAdminItem } from "../src/api/adminCommon";
|
||||
export type * from "../src/api/privacyRetention";
|
||||
export type { ResourceAccessExplanationOptions } from "../src/api/resourceAccess";
|
||||
export { default as FormSection } from "../src/components/FormSection";
|
||||
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "../src/api/mailContracts";
|
||||
export type * from "../src/api/mailContracts";
|
||||
export type * from "../src/types";
|
||||
export { default as FieldLabel } from "../src/components/help/FieldLabel";
|
||||
export { default as TextWithHelp } from "../src/components/help/TextWithHelp";
|
||||
export { default as PasswordField } from "../src/components/PasswordField";
|
||||
export { default as ResourceAccessExplanation } from "../src/components/ResourceAccessExplanation";
|
||||
export { default as ExplorerTree } from "../src/components/ExplorerTree";
|
||||
@@ -41,6 +46,7 @@ export { default as Button } from "../src/components/Button";
|
||||
export { default as Card } from "../src/components/Card";
|
||||
export { default as MetricGrid } from "../src/components/MetricGrid";
|
||||
export { default as MetricCard } from "../src/components/MetricCard";
|
||||
export { DashboardWidgetList, useDashboardWidgetData } from "../src/components/DashboardWidgetContent";
|
||||
export { default as PageActionBar } from "../src/components/PageActionBar";
|
||||
export { default as PageLayout } from "../src/components/PageLayout";
|
||||
export { default as PageTitle } from "../src/components/PageTitle";
|
||||
@@ -62,7 +68,7 @@ export { MailServerFolderLookupResultView } from "../src/components/mail/MailSer
|
||||
export type { MailServerFolderLookupResult } from "../src/components/mail/MailServerSettingsPanel";
|
||||
export { default as AdminSelectionList } from "../src/components/admin/AdminSelectionList";
|
||||
export { default as AdminPageLayout } from "../src/components/admin/AdminPageLayout";
|
||||
export { adminErrorMessage } from "../src/components/admin/adminUtils";
|
||||
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "../src/components/admin/adminUtils";
|
||||
export { default as ConnectionTree } from "../src/components/ConnectionTree";
|
||||
export type { ConnectionTreeColumn } from "../src/components/ConnectionTree";
|
||||
export { default as StageRail } from "../src/components/StageRail";
|
||||
@@ -113,6 +119,7 @@ export {
|
||||
usePlatformLanguage
|
||||
} from "../src/i18n/LanguageContext";
|
||||
export { usePlatformModuleInstalled, usePlatformUiCapability, usePlatformUiCapabilities, usePlatformModules } from "../src/platform/ModuleContext";
|
||||
export { dashboardWidgetsForModules } from "../src/platform/modules";
|
||||
export {
|
||||
dispatchQuickAccessResult,
|
||||
quickAccessLaunchState
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import DashboardGrid from "../../../govoplan-dashboard/webui/src/features/dashboard/DashboardGrid";
|
||||
import "../../../govoplan-dashboard/webui/src/styles/dashboard.css";
|
||||
import Button from "../src/components/Button";
|
||||
import { DocumentationHelpProvider } from "../src/components/help/DocumentationHelpLink";
|
||||
import type { AuthInfo, DashboardWidgetContribution } from "../src/types";
|
||||
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "widget-user", account_id: "widget-account", email: "widget@example.test" },
|
||||
tenant: { id: "widget-tenant", name: "Widget fixture", slug: "widget" },
|
||||
scopes: [], roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
const widget: DashboardWidgetContribution = {
|
||||
id: "fixture.scheduling",
|
||||
title: "Scheduling requests",
|
||||
documentation: { topicId: "scheduling.find-and-decide-meeting-time", documentationType: "user" },
|
||||
render: () => <p>Existing requests</p>
|
||||
};
|
||||
const ignore = () => undefined;
|
||||
|
||||
export default function WidgetHeadingHelpScenario() {
|
||||
const [configuring, setConfiguring] = useState(false);
|
||||
const [preview, setPreview] = useState(false);
|
||||
return <DocumentationHelpProvider localDocsAvailable>
|
||||
<main className="conformance-root">
|
||||
<Button onClick={() => { setConfiguring(value => !value); setPreview(false); }}>Toggle configuration</Button>
|
||||
<Button onClick={() => { setConfiguring(true); setPreview(value => !value); }}>Toggle drag preview</Button>
|
||||
<DashboardGrid
|
||||
placements={[{ instanceId: "fixture-instance", widgetId: widget.id, size: "medium", columnStart: 1, configuration: {} }]}
|
||||
widgetById={new Map([[widget.id, widget]])}
|
||||
settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} modules={[]}
|
||||
effectiveView={null} refreshKey={0} configuring={configuring}
|
||||
dragItem={preview ? { kind: "placement", instanceId: "fixture-instance" } : null}
|
||||
dropTarget={preview ? { kind: "end", columnStart: 1 } : null}
|
||||
onDragStart={ignore} onDragEnd={ignore} onDragOver={ignore} onDragOverEnd={ignore}
|
||||
onDrop={ignore} onDropPreview={ignore} onDropAtEnd={ignore} onRemove={ignore} onConfigure={ignore}
|
||||
/>
|
||||
</main>
|
||||
</DocumentationHelpProvider>;
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page) {
|
||||
async function install(page: Page, recipientData = false, fieldCount = 0) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => { errors.push(error.message); console.error("Attachment fixture:", error.message); });
|
||||
let revision = 1;
|
||||
const zip = { enabled: true, archives: [{ id: "zip-1", name: "recipient.zip", method: "zip_standard", password_enabled: true }] };
|
||||
let raw = { campaign: { name: "Fixture" }, template: { subject: "Fixture", text: "" }, server: {},
|
||||
...(recipientData ? { recipients: { allow_individual_to: true },
|
||||
fields: Array.from({ length: fieldCount }, (_, index) => ({ name: `field_${index + 1}`, label: `Field ${index + 1}`, type: "string", can_override: true })),
|
||||
entries: { defaults: {}, inline: [
|
||||
{ id: "recipient-1", name: "Fixture recipient", email: "recipient@example.test", channel_policy: "mail",
|
||||
print_target: { target: "Dispatch fixture", channel: "internal_mail" } }
|
||||
] } } : {}),
|
||||
attachments: { base_paths: [{ id: "source-1", name: "Source", path: ".", source: "managed:user:user-1", allow_individual: true }],
|
||||
global: [], zip } };
|
||||
const writes: Record<string, any>[] = [];
|
||||
const mutations: string[] = [];
|
||||
const version = () => ({ id: "version-attachments", campaign_id: "campaign-attachments", version_number: 1,
|
||||
edit_revision: revision, strong_etag: `"version-attachments:${revision}"`, editor_state: {},
|
||||
current_flow: "manual", current_step: "files", workflow_state: "editing", is_complete: false,
|
||||
updated_at: "2026-09-07T10:00:00Z", raw_json: raw });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() !== "GET") mutations.push(`${request.method()} ${url.pathname}`);
|
||||
if (request.method() === "GET") {
|
||||
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
|
||||
campaign: { id: "campaign-attachments", name: "Fixture", current_version_id: "version-attachments", status: "draft" },
|
||||
@@ -38,9 +46,10 @@ async function install(page: Page) {
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto("/?campaign-attachments");
|
||||
await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeEnabled();
|
||||
return { writes, errors, zip };
|
||||
await page.goto(`/?campaign-attachments${recipientData ? "&recipient-data" : ""}`);
|
||||
if (recipientData) await expect(page.locator('.recipient-profiles-table-surface .data-grid-body-cell[data-column-id="recipients"]')).toContainText("recipient@example.test");
|
||||
else await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeEnabled();
|
||||
return { writes, mutations, errors, zip };
|
||||
}
|
||||
|
||||
test("actual Files chooser opens repeatedly from attachment path by click and keyboard", async ({ page }) => {
|
||||
@@ -82,3 +91,134 @@ test("attachment source corrections save without changing or reauthorizing legac
|
||||
await expect(page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]')).toHaveValue("Updated source name");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("global attachment labels grow across the fixed source column without changing campaign data", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const card = page.locator("#campaign-global-attachments");
|
||||
const label = card.locator('.data-grid-header-cell[data-column-id="label"]');
|
||||
const basePath = card.locator('.data-grid-header-cell[data-column-id="base_path"]');
|
||||
const pattern = card.locator('.data-grid-header-cell[data-column-id="file_filter"]');
|
||||
const measured = (column: typeof label) => column.evaluate((element) => element.getBoundingClientRect().width);
|
||||
await expect(label).toBeVisible();
|
||||
const handle = label.getByRole("separator");
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
const initialLabel = await measured(label);
|
||||
const initialBasePath = await measured(basePath);
|
||||
const initialPattern = await measured(pattern);
|
||||
const bounds = (await handle.boundingBox())!;
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width / 2 + 80, bounds.y + bounds.height / 2, { steps: 6 });
|
||||
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 80, 0);
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 80, 0);
|
||||
await expect.poll(() => measured(basePath)).toBeCloseTo(initialBasePath, 0);
|
||||
await expect.poll(() => measured(pattern)).toBeCloseTo(initialPattern, 0);
|
||||
await handle.press("Shift+ArrowRight");
|
||||
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 120, 0);
|
||||
await page.reload();
|
||||
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 120, 0);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.mutations).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("recipient and delivery columns grow past the old caps across fixed neighbors and keep personal widths", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 2048, height: 1100 });
|
||||
const fixture = await install(page, true);
|
||||
const grid = page.locator(".recipient-profiles-table-surface");
|
||||
await expect(grid.locator('.data-grid-body-cell[data-column-id="delivery"]')).toContainText("Internal mail: Dispatch fixture");
|
||||
const column = (id: string) => grid.locator(`.data-grid-header-cell[data-column-id="${id}"]`);
|
||||
const measured = (id: string) => column(id).evaluate((element) => element.getBoundingClientRect().width);
|
||||
const fixedWidths = { active: await measured("active"), attachments: await measured("attachments") };
|
||||
const expectedWidths: Record<string, number> = {};
|
||||
for (const [id, oldMaximum] of [["recipients", 640], ["delivery", 480]] as const) {
|
||||
const handle = column(id).getByRole("separator");
|
||||
await grid.locator(".data-grid-scroll-region").evaluate((element, columnId) => {
|
||||
const header = element.querySelector<HTMLElement>(`.data-grid-header-cell[data-column-id="${columnId}"]`)!;
|
||||
element.scrollLeft = Math.max(0, header.offsetLeft - element.clientWidth / 4);
|
||||
}, id);
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
const initial = await measured(id);
|
||||
const target = Math.max(oldMaximum + 80, initial + 80);
|
||||
const bounds = (await handle.boundingBox())!;
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width / 2 + target - initial, bounds.y + bounds.height / 2, { steps: 6 });
|
||||
await expect.poll(() => measured(id)).toBeCloseTo(target, 0);
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => measured(id)).toBeCloseTo(target, 0);
|
||||
await handle.press("Shift+ArrowRight");
|
||||
expectedWidths[id] = target + 40;
|
||||
await expect.poll(() => measured(id)).toBeCloseTo(expectedWidths[id], 0);
|
||||
for (const fixed of ["active", "attachments"] as const) await expect.poll(() => measured(fixed)).toBeCloseTo(fixedWidths[fixed], 0);
|
||||
}
|
||||
expect(await grid.locator(".data-grid-scroll-region").evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true);
|
||||
await page.reload();
|
||||
for (const id of ["recipients", "delivery"]) await expect.poll(() => measured(id)).toBeCloseTo(expectedWidths[id], 0);
|
||||
await expect(grid.locator('.data-grid-body-cell[data-column-id="delivery"]')).toContainText("Internal mail: Dispatch fixture");
|
||||
for (const fixed of ["active", "attachments"] as const) await expect.poll(() => measured(fixed)).toBeCloseTo(fixedWidths[fixed], 0);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.mutations).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("ultrawide recipient grids keep balanced initial widths and resize in both directions across fixed columns", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 3085, height: 1200 });
|
||||
const fixture = await install(page, true, 3);
|
||||
const grid = page.locator(".recipient-profiles-table-surface");
|
||||
const region = grid.locator(".data-grid-scroll-region");
|
||||
const column = (id: string) => grid.locator(`.data-grid-header-cell[data-column-id="${id}"]`);
|
||||
const width = (id: string) => column(id).evaluate(element => element.getBoundingClientRect().width);
|
||||
await expect.poll(() => width("recipients")).toBeLessThanOrEqual(640.01);
|
||||
await expect.poll(() => width("delivery")).toBeLessThanOrEqual(480.01);
|
||||
const fixed = { active: await width("active"), attachments: await width("attachments") };
|
||||
const ready = async (id: string) => {
|
||||
const handle = column(id).getByRole("separator");
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
return handle;
|
||||
};
|
||||
const keyResize = async (id: string, grow: boolean, count = 1) => {
|
||||
const handle = await ready(id);
|
||||
for (let step = 0; step < count; step += 1) await handle.press(grow ? "Shift+ArrowRight" : "Shift+ArrowLeft");
|
||||
};
|
||||
|
||||
// All right-hand field columns must be adjustable beyond their old 360px
|
||||
// presentation caps, and shrinking must not silently snap back afterwards.
|
||||
for (const id of ["field-field_1", "field-field_2", "field-field_3"]) {
|
||||
const initial = await width(id);
|
||||
await keyResize(id, true, 6);
|
||||
await expect.poll(() => width(id)).toBeCloseTo(initial + 240, 0);
|
||||
await keyResize(id, false, 2);
|
||||
await expect.poll(() => width(id)).toBeCloseTo(initial + 160, 0);
|
||||
}
|
||||
const recipientStart = await width("recipients");
|
||||
await keyResize("recipients", true, 10);
|
||||
await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 400, 0);
|
||||
const recipientHandle = await ready("recipients");
|
||||
const bounds = (await recipientHandle.boundingBox())!;
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width / 2 - 160, bounds.y + bounds.height / 2, { steps: 8 });
|
||||
await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 240, 0);
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 240, 0);
|
||||
|
||||
const deliveryStart = await width("delivery");
|
||||
await keyResize("delivery", true, 3);
|
||||
await keyResize("delivery", false, 2);
|
||||
await expect.poll(() => width("delivery")).toBeCloseTo(deliveryStart + 40, 0);
|
||||
const savedWidths = await Promise.all(["recipients", "delivery", "field-field_1", "field-field_2", "field-field_3"].map(width));
|
||||
await page.reload();
|
||||
for (const [index, id] of ["recipients", "delivery", "field-field_1", "field-field_2", "field-field_3"].entries()) {
|
||||
await expect.poll(() => width(id)).toBeCloseTo(savedWidths[index], 0);
|
||||
}
|
||||
await (await ready("delivery")).focus();
|
||||
const deliveryBounds = (await column("delivery").boundingBox())!;
|
||||
const viewport = (await region.boundingBox())!;
|
||||
expect(deliveryBounds.x + deliveryBounds.width).toBeGreaterThan(viewport.x);
|
||||
expect(deliveryBounds.x).toBeLessThan(viewport.x + viewport.width);
|
||||
for (const id of ["active", "attachments"] as const) await expect.poll(() => width(id)).toBeCloseTo(fixed[id], 0);
|
||||
expect(fixture.mutations).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
const tableOnlyCards = ["direct-table", "collapsible-table", "loading-table", "admin-table", "loading-admin-table", "connection-table", "loading-connection-table", "explicit-table"];
|
||||
|
||||
async function expectFlush(card: Locator) {
|
||||
const geometry = await card.evaluate((element) => {
|
||||
const body = element.querySelector(".card-body")!;
|
||||
const surface = element.querySelector(".data-grid-shell, .connection-tree")!;
|
||||
const cardRect = element.getBoundingClientRect();
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const rect = surface.getBoundingClientRect();
|
||||
const grid = surface.querySelector(".data-grid");
|
||||
const scrollRegion = surface.querySelector(".data-grid-scroll-region");
|
||||
return {
|
||||
left: rect.left - cardRect.left, right: cardRect.right - rect.right,
|
||||
top: rect.top - bodyRect.top, bottom: cardRect.bottom - rect.bottom,
|
||||
padding: getComputedStyle(body).padding, border: getComputedStyle(surface).borderWidth,
|
||||
unfilledWidth: grid && scrollRegion ? scrollRegion.clientWidth - grid.getBoundingClientRect().width : 0
|
||||
};
|
||||
});
|
||||
expect(geometry.padding).toBe("0px");
|
||||
expect(geometry.border).toBe("0px");
|
||||
expect(geometry.unfilledWidth).toBeLessThanOrEqual(1);
|
||||
for (const edge of [geometry.left, geometry.right, geometry.top, geometry.bottom]) expect(Math.abs(edge)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
for (const width of [1280, 390, 320]) {
|
||||
test(`table-only cards use their full interior without negative margins at ${width}px`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto("/?data-grid-layout&table-cards&language=en&theme=light");
|
||||
for (const id of tableOnlyCards) await expectFlush(page.getByTestId(id));
|
||||
for (const id of ["mixed-content", "loading-mixed-content"]) {
|
||||
const body = page.getByTestId(id).locator(".card-body");
|
||||
await expect(body).toHaveCSS("padding", "22px 24px");
|
||||
await expect(body.locator(".data-grid-shell")).toHaveCSS("border-width", "1px");
|
||||
}
|
||||
const context = page.getByTestId("table-with-context");
|
||||
await expect(context.locator(".card-body")).toHaveCSS("padding", "0px");
|
||||
await expect(context.locator(".content-section")).toHaveCSS("padding", "18px");
|
||||
const contextBounds = await context.boundingBox();
|
||||
const contextGrid = await context.locator(".data-grid-shell").boundingBox();
|
||||
expect(contextGrid!.x - contextBounds!.x).toBeCloseTo(1, 0);
|
||||
expect(contextBounds!.width - contextGrid!.width).toBeCloseTo(2, 0);
|
||||
await expect(page.getByTestId("standalone-table").locator(".data-grid-shell")).toHaveCSS("border-width", "1px");
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("loading overlays and collapse do not change table insets or lose row actions", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 900 });
|
||||
await page.goto("/?data-grid-layout&table-cards&language=en&theme=light");
|
||||
const before = await page.getByTestId("loading-table").boundingBox();
|
||||
await page.getByRole("button", { name: "Toggle loading", exact: true }).click();
|
||||
for (const id of ["loading-table", "loading-admin-table", "loading-connection-table", "explicit-table"]) {
|
||||
const card = page.getByTestId(id);
|
||||
await expectFlush(card);
|
||||
const frame = await card.locator(".loading-frame").boundingBox();
|
||||
const overlay = await card.locator(".loading-frame-overlay").boundingBox();
|
||||
expect(overlay).toEqual(frame);
|
||||
await expect(card.locator(".loading-frame")).toHaveAttribute("aria-busy", "true");
|
||||
}
|
||||
expect(await page.getByTestId("loading-table").boundingBox()).toEqual(before);
|
||||
await page.getByRole("button", { name: "Toggle loading", exact: true }).click();
|
||||
const collapsible = page.getByTestId("collapsible-table");
|
||||
await collapsible.locator(".card-collapse-toggle").click();
|
||||
await expect(collapsible.locator(".data-grid-shell")).toHaveCount(0);
|
||||
await collapsible.locator(".card-collapse-toggle").click();
|
||||
await expectFlush(collapsible);
|
||||
const direct = page.getByTestId("direct-table");
|
||||
const scroller = direct.locator(".data-grid-scroll-region");
|
||||
expect(await scroller.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true);
|
||||
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
|
||||
const action = direct.getByRole("button", { name: "Inspect Alpha", exact: true });
|
||||
await action.click();
|
||||
await expect(page.getByTestId("card-table-inspected")).toHaveText("Alpha");
|
||||
});
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function expectDashboardTitleHelp(page: Page) {
|
||||
const title = page.getByRole("heading", { level: 1, name: new URL(page.url()).searchParams.get("language") === "de" ? "Übersicht" : "Dashboard", exact: true });
|
||||
await expect(title).toBeVisible();
|
||||
const anchor = title.locator("..");
|
||||
const help = anchor.getByRole("link");
|
||||
await expect(help).toBeVisible();
|
||||
await expect(title.locator("a")).toHaveCount(0);
|
||||
const headingBounds = await title.boundingBox();
|
||||
const helpBounds = await help.boundingBox();
|
||||
expect(helpBounds!.x - headingBounds!.x - headingBounds!.width).toBeGreaterThanOrEqual(4);
|
||||
expect(helpBounds!.x - headingBounds!.x - headingBounds!.width).toBeLessThanOrEqual(8);
|
||||
await expect(page.locator('[data-page-action-slot="help"], .page-action-bar-trailing .documentation-help-link')).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function dashboardFixture(page: Page) {
|
||||
const writes: string[] = [];
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.route(url => url.pathname.startsWith("/api/"), route => {
|
||||
if (route.request().method() !== "GET") writes.push(new URL(route.request().url()).pathname);
|
||||
return route.fulfill({ json: {
|
||||
exists: false, view_id: null, layout_version: 1, revision: 0,
|
||||
placements: [], known_widget_ids: [], updated_at: null
|
||||
} });
|
||||
});
|
||||
return { writes, errors };
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"]) {
|
||||
test(`Dashboard Cancel exits a clean configuration without saving (${language})`, async ({ page }) => {
|
||||
const fixture = await dashboardFixture(page);
|
||||
await page.goto(`/?dashboard-configuration&language=${language}`);
|
||||
await expectDashboardTitleHelp(page);
|
||||
const configure = page.locator('[data-page-action-archetype="overview"] [data-page-action-slot="primary"] button');
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await expect(configure).toBeEnabled();
|
||||
await configure.click();
|
||||
await expectDashboardTitleHelp(page);
|
||||
const editor = page.locator('[data-page-action-archetype="editor"]');
|
||||
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeDisabled();
|
||||
const cancel = editor.locator('[data-page-action-slot="discard"] button');
|
||||
await expect(cancel).toBeEnabled();
|
||||
await expect(cancel).toHaveText(language === "de" ? "Abbrechen" : "Cancel");
|
||||
await cancel.click();
|
||||
await expect(configure).toBeVisible();
|
||||
await expectDashboardTitleHelp(page);
|
||||
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
||||
}
|
||||
expect(fixture.writes).toEqual([]);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("Dashboard Cancel confirms dirty drafts and never saves when discarding", async ({ page }) => {
|
||||
const fixture = await dashboardFixture(page);
|
||||
await page.goto("/?dashboard-configuration&language=en");
|
||||
const configure = page.locator('[data-page-action-archetype="overview"] [data-page-action-slot="primary"] button');
|
||||
await configure.click();
|
||||
await page.getByRole("button", { name: "Remove Active interface modules", exact: true }).click();
|
||||
await expectDashboardTitleHelp(page);
|
||||
const editor = page.locator('[data-page-action-archetype="editor"]');
|
||||
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeEnabled();
|
||||
const cancel = editor.locator('[data-page-action-slot="discard"] button');
|
||||
await cancel.click();
|
||||
const confirmation = page.getByRole("alertdialog");
|
||||
await expect(confirmation).toBeVisible();
|
||||
await confirmation.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeEnabled();
|
||||
await cancel.click();
|
||||
await confirmation.getByRole("button", { name: "Discard", exact: true }).click();
|
||||
await expect(configure).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Active interface modules", exact: true })).toBeVisible();
|
||||
await configure.click();
|
||||
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeDisabled();
|
||||
await expect(cancel).toBeEnabled();
|
||||
expect(fixture.writes).toEqual([]);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("Dashboard Cancel can save and leave without restoring the previous draft", async ({ page }) => {
|
||||
const fixture = await dashboardFixture(page);
|
||||
let releaseSave!: () => void;
|
||||
const savePending = new Promise<void>(resolve => { releaseSave = resolve; });
|
||||
let saved: Record<string, unknown> | null = null;
|
||||
const updates: Record<string, unknown>[] = [];
|
||||
await page.route("**/api/v1/dashboard/layout*", async route => {
|
||||
if (route.request().method() === "PUT") {
|
||||
const payload = route.request().postDataJSON();
|
||||
updates.push(payload);
|
||||
await savePending;
|
||||
saved = { ...payload, exists: true, view_id: null, revision: 1, updated_at: "2026-09-08T12:00:00Z" };
|
||||
await route.fulfill({ json: saved });
|
||||
} else if (saved) {
|
||||
await route.fulfill({ json: saved });
|
||||
} else {
|
||||
await route.fallback();
|
||||
}
|
||||
});
|
||||
await page.goto("/?dashboard-configuration&language=en");
|
||||
const configure = page.locator('[data-page-action-archetype="overview"] [data-page-action-slot="primary"] button');
|
||||
const editor = page.locator('[data-page-action-archetype="editor"]');
|
||||
await configure.click();
|
||||
await page.getByRole("button", { name: "Remove Active interface modules", exact: true }).click();
|
||||
await editor.locator('[data-page-action-slot="discard"] button').click();
|
||||
const confirmation = page.getByRole("alertdialog");
|
||||
await confirmation.getByRole("button", { name: "Save and leave", exact: true }).click();
|
||||
await expect.poll(() => updates.length).toBe(1);
|
||||
await expect(editor.locator('[data-page-action-slot="discard"] button')).toBeDisabled();
|
||||
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeDisabled();
|
||||
await expect(confirmation.getByRole("button", { name: "Discard", exact: true })).toBeDisabled();
|
||||
releaseSave();
|
||||
await expect(configure).toBeVisible();
|
||||
await expect(confirmation).toHaveCount(0);
|
||||
expect(updates[0].placements).toEqual([]);
|
||||
await page.reload();
|
||||
await configure.click();
|
||||
await expect(page.getByRole("button", { name: "Remove Active interface modules", exact: true })).toHaveCount(0);
|
||||
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeDisabled();
|
||||
await expect(editor.locator('[data-page-action-slot="discard"] button')).toBeEnabled();
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("actual Scheduling widget contributes help at its Dashboard title without duplicating it", async ({ page }) => {
|
||||
const fixture = await dashboardFixture(page);
|
||||
await page.route("**/api/v1/dashboard/layout*", route => route.fulfill({ json: {
|
||||
exists: true, view_id: null, layout_version: 1, revision: 1,
|
||||
placements: [{ instance_id: "scheduling-widget", widget_id: "scheduling.open-requests", size: "medium", column_start: 1, configuration: {} }],
|
||||
known_widget_ids: ["dashboard.installed-modules", "scheduling.open-requests"], updated_at: "2026-09-08T12:00:00Z"
|
||||
} }));
|
||||
await page.route("**/api/v1/scheduling/requests*", route => route.fulfill({ json: { requests: [] } }));
|
||||
await page.goto("/?dashboard-configuration&widget-help&language=en");
|
||||
await expectDashboardTitleHelp(page);
|
||||
const widget = page.locator(".dashboard-widget").filter({ has: page.getByRole("heading", { level: 2, name: "Scheduling requests", exact: true }) });
|
||||
await expect(widget).toHaveCount(1);
|
||||
await expect(widget.getByRole("heading", { name: "Scheduling requests", exact: true })).toHaveCount(1);
|
||||
const titleHelp = widget.locator(".card-title-with-help .documentation-help-link");
|
||||
await expect(titleHelp).toHaveAttribute("href", /topic=scheduling\.find-and-decide-meeting-time/);
|
||||
await expect(titleHelp).toBeVisible();
|
||||
await expect(widget.locator(".card-body .documentation-help-link")).toHaveCount(0);
|
||||
await widget.getByRole("button", { name: "Show header only", exact: true }).click();
|
||||
await expect(titleHelp).toBeVisible();
|
||||
await page.locator('[data-page-action-archetype="overview"] [data-page-action-slot="primary"] button').click();
|
||||
await expectDashboardTitleHelp(page);
|
||||
await expect(titleHelp).toBeVisible();
|
||||
await expect(widget.locator(".card-actions .documentation-help-link")).toHaveCount(0);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
@@ -117,6 +117,74 @@ test("constrained resizing redistributes tracks without introducing overflow", a
|
||||
await expectActionsUnclipped(page);
|
||||
});
|
||||
|
||||
for (const mode of ["cover", "free", "constrained"] as const) {
|
||||
test(`${mode} resizing crosses a fixed intermediate column without changing that column`, async ({ page }) => {
|
||||
await page.goto(`/?data-grid-layout&mixed-columns&mode=${mode}`);
|
||||
const handle = header(page, "name").getByRole("separator");
|
||||
await expect.poll(() => width(page, "actions")).toBeGreaterThanOrEqual(mode === "free" ? 180 : 181);
|
||||
if (mode !== "free") await expectActionsUnclipped(page);
|
||||
const initialName = await width(page, "name");
|
||||
const initialDetail = await width(page, "detail");
|
||||
const initialFixed = await width(page, "fixed");
|
||||
const initialGrid = await page.locator(".data-grid").evaluate((element) => element.getBoundingClientRect().width);
|
||||
const box = (await handle.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2, { steps: 6 });
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 80, 0);
|
||||
await expect.poll(() => width(page, "fixed")).toBeCloseTo(initialFixed, 0);
|
||||
await expect.poll(() => width(page, "detail")).toBeCloseTo(initialDetail - (mode === "constrained" ? 80 : 0), 0);
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 80, 0);
|
||||
await expect.poll(() => page.locator(".data-grid").evaluate((element) => element.getBoundingClientRect().width))
|
||||
.toBeCloseTo(initialGrid + (mode === "constrained" ? 0 : 80), 0);
|
||||
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 80, 0);
|
||||
await handle.press("Shift+ArrowLeft");
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 40, 0);
|
||||
await expect.poll(() => width(page, "fixed")).toBeCloseTo(initialFixed, 0);
|
||||
await handle.press("Shift+ArrowRight");
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 80, 0);
|
||||
await expect.poll(() => width(page, "fixed")).toBeCloseTo(initialFixed, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("preferred caps permit two-way resizing across fixed peers at the right scroll boundary", async ({ page }) => {
|
||||
await page.goto("/?data-grid-layout&mixed-columns&preferred-limits");
|
||||
await expectActionsUnclipped(page);
|
||||
const initialName = await width(page, "name");
|
||||
const initialFixed = await width(page, "fixed");
|
||||
const nameHandle = header(page, "name").getByRole("separator");
|
||||
for (let step = 0; step < 10; step += 1) await nameHandle.press("Shift+ArrowRight");
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 400, 0);
|
||||
const scroller = page.locator(".data-grid-scroll-region");
|
||||
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
|
||||
const detailBeforeShrink = await width(page, "detail");
|
||||
const scrollBeforeShrink = await scroller.evaluate((element) => element.scrollLeft);
|
||||
const handleBeforeShrink = (await nameHandle.boundingBox())!;
|
||||
await page.mouse.move(handleBeforeShrink.x + handleBeforeShrink.width / 2, handleBeforeShrink.y + handleBeforeShrink.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handleBeforeShrink.x + handleBeforeShrink.width / 2 - 80, handleBeforeShrink.y + handleBeforeShrink.height / 2, { steps: 6 });
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 320, 0);
|
||||
await expect.poll(() => width(page, "detail")).toBeCloseTo(detailBeforeShrink + 80, 0);
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollLeft)).toBeCloseTo(scrollBeforeShrink, 0);
|
||||
expect((await nameHandle.boundingBox())!.x).toBeCloseTo(handleBeforeShrink.x - 80, 0);
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 320, 0);
|
||||
const detailHandle = header(page, "detail").getByRole("separator");
|
||||
await detailHandle.press("Shift+ArrowRight");
|
||||
await expect.poll(() => width(page, "detail")).toBeCloseTo(detailBeforeShrink + 120, 0);
|
||||
await detailHandle.press("Shift+ArrowLeft");
|
||||
await expect.poll(() => width(page, "detail")).toBeCloseTo(detailBeforeShrink + 80, 0);
|
||||
await expect.poll(() => width(page, "fixed")).toBeCloseTo(initialFixed, 0);
|
||||
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 320, 0);
|
||||
await expect.poll(() => width(page, "detail")).toBeCloseTo(detailBeforeShrink + 80, 0);
|
||||
await expect.poll(() => width(page, "fixed")).toBeCloseTo(initialFixed, 0);
|
||||
});
|
||||
|
||||
test("explicit composite action groups include outer controls in their measured minimum", async ({ page }) => {
|
||||
await page.goto("/?data-grid-layout&mode=composite");
|
||||
await expect.poll(() => width(page, "actions")).toBeGreaterThanOrEqual(221);
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
async function expectHelpBesideHeading(anchor: Locator) {
|
||||
const heading = anchor.locator(":scope > :is(h1,h2,h3)");
|
||||
const link = anchor.getByRole("link");
|
||||
await expect(heading).toBeVisible();
|
||||
await expect(link).toBeVisible();
|
||||
await expect(heading.locator("a,button,[role=status]")).toHaveCount(0);
|
||||
const geometry = await anchor.evaluate(element => {
|
||||
const heading = element.querySelector(":scope > :is(h1,h2,h3)")!.getBoundingClientRect();
|
||||
const link = element.querySelector("a")!.getBoundingClientRect();
|
||||
return { gap: link.left - heading.right, right: link.right, viewport: window.innerWidth };
|
||||
});
|
||||
expect(geometry.gap).toBeGreaterThanOrEqual(4);
|
||||
expect(geometry.gap).toBeLessThanOrEqual(8);
|
||||
expect(geometry.right).toBeLessThanOrEqual(geometry.viewport);
|
||||
}
|
||||
|
||||
for (const width of [390, 3085]) {
|
||||
for (const language of ["en", "de"]) {
|
||||
test(`documentation remains beside headings during loading and long-title wrapping (${width}px, ${language})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1100 });
|
||||
await page.goto(`/?heading-help&language=${language}`);
|
||||
const pageTitle = page.locator(".page-title-with-loader");
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Dashboard", exact: true })).toBeVisible();
|
||||
await expect(pageTitle.getByRole("status")).toBeVisible();
|
||||
await expect(pageTitle.getByRole("link")).toHaveAccessibleName(language === "de" ? "Benutzerdokumentation öffnen" : "Open user documentation");
|
||||
for (const anchor of await page.locator(".text-with-help:has(> :is(h1,h2,h3))").all()) await expectHelpBesideHeading(anchor);
|
||||
const rawLabel = page.getByTestId("raw-text-help");
|
||||
const rawGeometry = await rawLabel.evaluate(element => ({ width: element.clientWidth, scrollWidth: element.scrollWidth, right: element.getBoundingClientRect().right, helpRight: element.querySelector("a")!.getBoundingClientRect().right }));
|
||||
expect(rawGeometry.scrollWidth).toBeLessThanOrEqual(rawGeometry.width);
|
||||
expect(rawGeometry.helpRight).toBeLessThanOrEqual(rawGeometry.right);
|
||||
await page.getByRole("button", { name: "Toggle loading", exact: true }).click();
|
||||
await expect(pageTitle.getByRole("status")).toHaveCount(0);
|
||||
await expectHelpBesideHeading(pageTitle.locator(".text-with-help"));
|
||||
await expect(page.locator('[data-page-action-slot="help"]')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("card help stays available when collapsed and opening it does not toggle content", async ({ page, context }) => {
|
||||
await context.route("https://govoplan.add-ideas.de/**", route => route.fulfill({ body: "Documentation fixture" }));
|
||||
await page.goto("/?heading-help&language=en");
|
||||
const card = page.getByTestId("help-card");
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await card.getByRole("link").click();
|
||||
const popup = await popupPromise;
|
||||
await popup.close();
|
||||
await expect(page.getByTestId("help-card-content")).toBeVisible();
|
||||
await card.getByRole("button", { name: "Show header only", exact: true }).click();
|
||||
await expect(page.getByTestId("help-card-content")).toHaveCount(0);
|
||||
await expectHelpBesideHeading(card.locator(".text-with-help"));
|
||||
await card.getByRole("button", { name: "Show content", exact: true }).click();
|
||||
await expect(page.getByTestId("help-card-content")).toBeVisible();
|
||||
});
|
||||
|
||||
test("dialog help has its own name, remains in keyboard order, and does not dismiss the dialog", async ({ page, context }) => {
|
||||
await context.route("https://govoplan.add-ideas.de/**", route => route.fulfill({ body: "Documentation fixture" }));
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
await page.goto("/?heading-help&language=en");
|
||||
await page.getByTestId("open-help-dialog").click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toHaveAccessibleName(/^Dashboard DokumentationsüberschriftOhneLeerzeichen/);
|
||||
await expectHelpBesideHeading(dialog.locator(".text-with-help"));
|
||||
const help = dialog.getByRole("link");
|
||||
await expect(help).toBeFocused();
|
||||
await help.press("Shift+Tab");
|
||||
await expect(page.getByTestId("dialog-last-action")).toBeFocused();
|
||||
await page.getByTestId("dialog-last-action").press("Tab");
|
||||
await expect(help).toBeFocused();
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await help.click();
|
||||
const popup = await popupPromise;
|
||||
await popup.close();
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByTestId("open-help-dialog")).toBeFocused();
|
||||
});
|
||||
|
||||
test("widget documentation stays beside its one existing title in display, configuration, and drag markup", async ({ page }) => {
|
||||
await page.goto("/?heading-help&widgets&language=en");
|
||||
const title = page.getByRole("heading", { name: "Scheduling requests", exact: true });
|
||||
const anchor = page.locator(".card-title-with-help");
|
||||
await expect(title).toHaveCount(1);
|
||||
await expectHelpBesideHeading(anchor);
|
||||
await expect(anchor.getByRole("link")).toHaveAttribute("href", "/docs?type=user&topic=scheduling.find-and-decide-meeting-time");
|
||||
await page.getByRole("button", { name: "Toggle configuration", exact: true }).click();
|
||||
await expect(title).toHaveCount(1);
|
||||
await expectHelpBesideHeading(anchor);
|
||||
await expect(page.locator(".card-actions .documentation-help-link")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Toggle drag preview", exact: true }).click();
|
||||
// A drag placeholder retains hidden content only to preserve the widget height.
|
||||
const preview = page.locator(".dashboard-widget-placeholder-content");
|
||||
await expect(preview.locator("h2")).toHaveCount(1);
|
||||
await expect(preview.locator(".card-title-with-help .documentation-help-link")).toHaveAttribute("href", "/docs?type=user&topic=scheduling.find-and-decide-meeting-time");
|
||||
await expect(preview).toBeHidden();
|
||||
await page.getByRole("button", { name: "Toggle drag preview", exact: true }).click();
|
||||
await expect(title).toHaveCount(1);
|
||||
await expectHelpBesideHeading(anchor);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ test("collapsed rail keeps visible group separators; opening settings is clean",
|
||||
expect((await separator.boundingBox())!.width).toBeGreaterThan(20);
|
||||
}
|
||||
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
|
||||
await expect(page.locator('[data-navigation-layout-status="inherited"]')).toContainText("standard layout groups available modules by product area");
|
||||
const spacing = await page.locator(".navigation-preference-list > li").first().evaluate((row) => {
|
||||
const style = getComputedStyle(row);
|
||||
return { padding: Number.parseFloat(style.paddingLeft), gap: Number.parseFloat(style.columnGap) };
|
||||
@@ -72,9 +73,14 @@ test("no-op reordering stays clean and optional module positions survive other e
|
||||
await handle.press("Space"); await handle.press("Enter");
|
||||
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
|
||||
await page.goto("/?navigation-layout&unavailable");
|
||||
await expect(page.locator('[data-navigation-layout-status="flat"]')).toContainText("without group headings or dividers");
|
||||
await expect(page.locator(".icon-rail").getByRole("separator")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Move Mail up", exact: true }).click();
|
||||
const draft = JSON.parse(await page.getByTestId("navigation-draft").textContent() ?? "null");
|
||||
expect(draft.order).toContain("optional.navigation.absent");
|
||||
await page.getByRole("button", { name: "Use inherited layout", exact: true }).click();
|
||||
await expect(page.locator(".icon-rail").getByRole("separator")).toHaveCount(2);
|
||||
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
|
||||
});
|
||||
|
||||
test("German narrow editor does not overflow and read-only controls cannot mutate", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const axePath = createRequire(import.meta.url).resolve("axe-core/axe.min.js");
|
||||
|
||||
const currentPassword = "current-password-fixture";
|
||||
const nextPassword = "new-password-fixture";
|
||||
const recoveryCode = "pr_fixture-code-never-a-real-secret";
|
||||
const policy = { recovery_enabled: true, min_length: 10, max_length: 1024, recovery_minutes: 15 };
|
||||
|
||||
async function mockPasswordApi(page: Page, options: { enabled?: boolean; failChange?: boolean; failRecovery?: boolean } = {}) {
|
||||
const posts: Array<{ path: string; body: Record<string, unknown> }> = [];
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (request.method() === "POST") posts.push({ path, body: request.postDataJSON() });
|
||||
const json = (data: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(data) });
|
||||
if (path === "/api/v1/auth/password/policy") return json({ ...policy, recovery_enabled: options.enabled ?? true });
|
||||
if (path === "/api/v1/auth/password/change") {
|
||||
if (options.failChange) return json({ detail: { code: "current_password_invalid", input: currentPassword } }, 403);
|
||||
return json({ user: { required_auth_action: null, local_password: true }, principal: { auth_method: "session", session_id: "rotated-session" } });
|
||||
}
|
||||
if (path.startsWith("/api/v1/auth/password/recovery/")) return json({ recovery_code: recoveryCode, expires_at: "2026-10-01T10:15:00Z" });
|
||||
if (path === "/api/v1/auth/password/recover") return options.failRecovery
|
||||
? json({ detail: { code: "recovery_invalid", input: recoveryCode } }, 400) : json({ ok: true });
|
||||
if (path === "/api/v1/admin/system/accounts/delta") return json({ accounts: [
|
||||
{ account_id: "target-local", email: "local@example.test", local_password: true, is_active: true, roles: [], memberships: [] },
|
||||
{ account_id: "target-external", email: "external@example.test", local_password: false, is_active: true, roles: [], memberships: [] }
|
||||
], roles: [], deleted: [], watermark: "fixture", full: true, has_more: false });
|
||||
if (path.endsWith("/tenants")) return json({ tenants: [] });
|
||||
// All browser verification uses synthetic responses; nothing reaches a live provider.
|
||||
return json({ detail: "Unmocked fixture request" }, 404);
|
||||
});
|
||||
return posts;
|
||||
}
|
||||
|
||||
async function expectNoSecretsInStorage(page: Page) {
|
||||
const values = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }));
|
||||
for (const secret of [currentPassword, nextPassword, recoveryCode]) {
|
||||
expect(values).not.toContain(secret);
|
||||
expect(page.url()).not.toContain(secret);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHelpContext(control: Locator, context: string) {
|
||||
await expect(control).toBeVisible();
|
||||
expect(await control.evaluate((element) => {
|
||||
const scoped = element.closest<HTMLElement>("[data-help-context-id]");
|
||||
return { context: scoped?.dataset.helpContextId, module: scoped?.dataset.helpModuleId };
|
||||
})).toEqual({ context, module: "access" });
|
||||
}
|
||||
|
||||
test("required-action F1 resolves public static help without privileged API calls or secret queries", async ({ page }) => {
|
||||
await mockPasswordApi(page);
|
||||
const apiRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname.startsWith("/api/v1/")) apiRequests.push(new URL(request.url()).pathname);
|
||||
});
|
||||
await page.goto("/?password-lifecycle&mode=required&language=en");
|
||||
const current = page.getByLabel("Current password", { exact: true });
|
||||
await current.fill(currentPassword);
|
||||
await expectHelpContext(current, "access.password.change");
|
||||
await expectHelpContext(page.getByLabel("New password", { exact: true }), "access.password.change");
|
||||
await expectHelpContext(page.getByLabel("Confirm new password", { exact: true }), "access.password.change");
|
||||
await expectHelpContext(page.getByRole("button", { name: "Change password", exact: true }), "access.password.change");
|
||||
await current.press("F1");
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.locator('[data-help-context="access.password.change"]')).toBeVisible();
|
||||
await expect(dialog).toContainText("access.help.password-change");
|
||||
await expect(dialog).not.toContainText(currentPassword);
|
||||
await page.evaluate(() => {
|
||||
window.open = (url) => {
|
||||
document.body.dataset.openedHelpUrl = String(url);
|
||||
return null;
|
||||
};
|
||||
});
|
||||
await dialog.getByRole("button", { name: "Open user documentation", exact: true }).click();
|
||||
const opened = new URL(await page.locator("body").getAttribute("data-opened-help-url") ?? "");
|
||||
expect(opened.origin).toBe("https://govoplan.add-ideas.de");
|
||||
expect(opened.searchParams.get("topic")).toBe("access.help.password-change");
|
||||
expect(opened.searchParams.get("module")).toBe("access");
|
||||
expect(opened.href).not.toContain(currentPassword);
|
||||
expect(apiRequests.every((path) => path === "/api/v1/auth/password/policy")).toBe(true);
|
||||
await expect(page.getByText("Workspace available")).toHaveCount(0);
|
||||
await expectNoSecretsInStorage(page);
|
||||
});
|
||||
|
||||
test("recovery credentials, verification, one-time display and navigation have exact owning help", async ({ page }) => {
|
||||
await mockPasswordApi(page);
|
||||
await page.goto("/?password-lifecycle&mode=recover&language=en");
|
||||
for (const label of ["Email", "Recovery code", "New password", "Confirm new password"]) {
|
||||
await expectHelpContext(page.getByLabel(label, { exact: true }), "access.password.recover");
|
||||
}
|
||||
await expectHelpContext(page.getByRole("button", { name: "Recover local password", exact: true }), "access.password.recover");
|
||||
await expectHelpContext(page.getByRole("link", { name: "Return to sign in", exact: true }), "access.password.recover");
|
||||
await page.goto("/?password-lifecycle&mode=login&language=en");
|
||||
await expectHelpContext(page.getByRole("link", { name: "Forgot your password?", exact: true }), "access.password.recover");
|
||||
await page.goto("/?password-lifecycle&mode=admin&language=en");
|
||||
await expectHelpContext(page.getByRole("button", { name: "Issue recovery code", exact: true }), "access.password.issue-recovery");
|
||||
await page.goto("/?password-lifecycle&mode=issue&language=en");
|
||||
const current = page.getByLabel("Current password", { exact: true });
|
||||
await expectHelpContext(current, "access.password.issue-recovery");
|
||||
const verified = page.getByRole("checkbox");
|
||||
await expectHelpContext(verified, "access.password.issue-recovery");
|
||||
const issue = page.getByRole("button", { name: "Issue recovery code", exact: true });
|
||||
await expectHelpContext(issue, "access.password.issue-recovery");
|
||||
await current.fill(currentPassword);
|
||||
await verified.check();
|
||||
await issue.click();
|
||||
await expectHelpContext(page.getByLabel("Recovery code", { exact: true }), "access.password.issue-recovery");
|
||||
await expectHelpContext(page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last(), "access.password.issue-recovery");
|
||||
});
|
||||
|
||||
test("required password change gates workspace and accepts the rotated cookie session", async ({ page }) => {
|
||||
const posts = await mockPasswordApi(page);
|
||||
await page.goto("/?password-lifecycle&mode=required&language=en");
|
||||
await expect(page.getByRole("heading", { name: "Change your initial password" })).toBeVisible();
|
||||
await expect(page.getByText("Workspace available")).toHaveCount(0);
|
||||
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
|
||||
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
|
||||
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
|
||||
await page.getByRole("button", { name: "Change password", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Workspace available" })).toBeVisible();
|
||||
expect(posts).toEqual([{ path: "/api/v1/auth/password/change", body: { current_password: currentPassword, new_password: nextPassword } }]);
|
||||
await expect(page.getByTestId("auth-update")).toHaveText(JSON.stringify({ action: null, token: "", session: "rotated-session" }));
|
||||
await expectNoSecretsInStorage(page);
|
||||
});
|
||||
|
||||
test("missing optional auth UI keeps the required account out of the workspace", async ({ page }) => {
|
||||
await mockPasswordApi(page);
|
||||
await page.goto("/?password-lifecycle&mode=missing&language=en");
|
||||
await expect(page.getByText(/A required account action must be completed/)).toBeVisible();
|
||||
await expect(page.getByText("Workspace available")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("password limits count Unicode code points, including astral characters", async ({ page }) => {
|
||||
const posts = await mockPasswordApi(page);
|
||||
const unicodeCurrent = "🔑".repeat(600);
|
||||
const unicodeNext = "🔐".repeat(1024);
|
||||
await page.goto("/?password-lifecycle&mode=settings&language=en");
|
||||
await page.getByLabel("Current password", { exact: true }).fill(unicodeCurrent);
|
||||
const password = page.getByLabel("New password", { exact: true });
|
||||
const confirmation = page.getByLabel("Confirm new password", { exact: true });
|
||||
await password.fill("a".repeat(1025));
|
||||
await confirmation.fill("a".repeat(1025));
|
||||
await expect(page.getByRole("button", { name: "Change password", exact: true })).toBeDisabled();
|
||||
await password.fill(unicodeNext);
|
||||
await confirmation.fill(unicodeNext);
|
||||
await expect(password).toHaveValue(unicodeNext);
|
||||
await page.getByRole("button", { name: "Change password", exact: true }).click();
|
||||
await expect(page.getByTestId("auth-update")).toContainText("rotated-session");
|
||||
expect(posts[0].body).toEqual({ current_password: unicodeCurrent, new_password: unicodeNext });
|
||||
});
|
||||
|
||||
test("self-service is available with recovery disabled and clears rejected credentials", async ({ page }) => {
|
||||
const posts = await mockPasswordApi(page, { enabled: false, failChange: true });
|
||||
await page.goto("/?password-lifecycle&mode=settings&language=en");
|
||||
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
|
||||
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
|
||||
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
|
||||
await page.getByRole("button", { name: "Change password", exact: true }).click();
|
||||
await expect(page.getByText(/Your current password was not accepted/)).toBeVisible();
|
||||
expect(posts).toHaveLength(1);
|
||||
for (const label of ["Current password", "New password", "Confirm new password"]) await expect(page.getByLabel(label, { exact: true })).toHaveValue("");
|
||||
await expect(page.locator("body")).not.toContainText(currentPassword);
|
||||
await expectNoSecretsInStorage(page);
|
||||
});
|
||||
|
||||
test("external accounts and API-key sessions cannot use the password change form", async ({ page }) => {
|
||||
const posts = await mockPasswordApi(page);
|
||||
for (const query of ["external=true", "api-key=true"]) {
|
||||
await page.goto(`/?password-lifecycle&mode=settings&language=en&${query}`);
|
||||
await expect(page.getByText(/Password changes require an interactive session/)).toBeVisible();
|
||||
await expect(page.getByLabel("Current password", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
expect(posts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("recovery replaces the password without signing in and clears all secrets", async ({ page }) => {
|
||||
const posts = await mockPasswordApi(page);
|
||||
await page.goto("/?password-lifecycle&mode=recover&language=en");
|
||||
await page.getByLabel("Email", { exact: true }).fill("person@example.test");
|
||||
await page.getByLabel("Recovery code", { exact: true }).fill(recoveryCode);
|
||||
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
|
||||
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
|
||||
await page.getByRole("button", { name: "Recover local password", exact: true }).click();
|
||||
await expect(page.getByText(/Your password was replaced and existing sessions/)).toBeVisible();
|
||||
expect(posts).toEqual([{ path: "/api/v1/auth/password/recover", body: { email: "person@example.test", recovery_code: recoveryCode, new_password: nextPassword } }]);
|
||||
await expect(page.getByTestId("auth-update")).toHaveText("");
|
||||
await expect(page.getByRole("link", { name: "Return to sign in" })).toHaveAttribute("href", "/");
|
||||
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
|
||||
await expectNoSecretsInStorage(page);
|
||||
});
|
||||
|
||||
test("expired recovery codes show translated errors without reflecting response secrets", async ({ page }) => {
|
||||
await mockPasswordApi(page, { failRecovery: true });
|
||||
await page.goto("/?password-lifecycle&mode=recover&language=de");
|
||||
await page.getByLabel("E-Mail", { exact: true }).fill("person@example.test");
|
||||
await page.getByLabel("Wiederherstellungscode", { exact: true }).fill(recoveryCode);
|
||||
await page.getByLabel("Neues Passwort", { exact: true }).fill(nextPassword);
|
||||
await page.getByLabel("Neues Passwort bestätigen", { exact: true }).fill(nextPassword);
|
||||
await page.getByRole("button", { name: "Lokales Passwort wiederherstellen", exact: true }).click();
|
||||
await expect(page.getByText(/Dieser Wiederherstellungscode ist ungültig/)).toBeVisible();
|
||||
await expect(page.getByLabel("Wiederherstellungscode", { exact: true })).toHaveValue("");
|
||||
await expect(page.locator("body")).not.toContainText(recoveryCode);
|
||||
});
|
||||
|
||||
test("issuing a code requires independent identity verification and discards the one-time display on close", async ({ page }) => {
|
||||
const posts = await mockPasswordApi(page);
|
||||
await page.goto("/?password-lifecycle&mode=issue&language=en");
|
||||
const issue = page.getByRole("button", { name: "Issue recovery code", exact: true });
|
||||
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
|
||||
await expect(issue).toBeDisabled();
|
||||
await page.getByRole("checkbox", { name: /I independently verified/ }).check();
|
||||
await issue.click();
|
||||
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveValue(recoveryCode);
|
||||
expect(posts).toEqual([{ path: "/api/v1/auth/password/recovery/target-1", body: { current_password: currentPassword, identity_verified: true } }]);
|
||||
await expect(page.getByText(/Expires:/)).toBeVisible();
|
||||
await expectNoSecretsInStorage(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
await page.getByRole("button", { name: "Reopen recovery" }).click();
|
||||
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByLabel("Current password", { exact: true })).toHaveValue("");
|
||||
await expect(page.getByRole("checkbox", { name: /I independently verified/ })).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("System account recovery actions require a local interactive System owner and a local target", async ({ page }) => {
|
||||
await mockPasswordApi(page);
|
||||
await page.goto("/?password-lifecycle&mode=admin&language=en");
|
||||
await expect(page.getByRole("button", { name: "Issue recovery code", exact: true })).toHaveCount(1);
|
||||
for (const query of ["owner=false", "external=true", "api-key=true"]) {
|
||||
await page.goto(`/?password-lifecycle&mode=admin&language=en&${query}`);
|
||||
await expect(page.getByText("local@example.test", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Issue recovery code", exact: true })).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("forgot-password link and recovery controls follow the disabled policy", async ({ page }) => {
|
||||
await mockPasswordApi(page, { enabled: false });
|
||||
await page.goto("/?password-lifecycle&mode=login&language=en");
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Forgot your password?" })).toHaveCount(0);
|
||||
await page.goto("/?password-lifecycle&mode=recover&language=en");
|
||||
await expect(page.getByText(/Administrator-assisted password recovery is not enabled/)).toBeVisible();
|
||||
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("enabled forgot-password link navigates without credentials in its URL", async ({ page }) => {
|
||||
await mockPasswordApi(page);
|
||||
await page.goto("/?password-lifecycle&mode=login&language=en");
|
||||
const link = page.getByRole("link", { name: "Forgot your password?" });
|
||||
await expect(link).toHaveAttribute("href", "/password-recovery");
|
||||
await link.click();
|
||||
await expect(page).toHaveURL(/\/password-recovery$/);
|
||||
});
|
||||
|
||||
for (const [language, theme] of [["en", "light"], ["de", "light"], ["en", "dark"], ["de", "dark"]]) {
|
||||
test(`required password form is accessible on mobile in ${language} ${theme}`, async ({ page }, testInfo) => {
|
||||
await mockPasswordApi(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`/?password-lifecycle&mode=required&language=${language}&theme=${theme}`);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
await page.addScriptTag({ path: axePath });
|
||||
const violations = await page.evaluate(async () => {
|
||||
const axe = (window as typeof window & { axe: { run: (options: unknown) => Promise<{ violations: Array<{ id: string; nodes: Array<{ target: unknown; failureSummary?: string }> }> }> } }).axe;
|
||||
const result = await axe.run({ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] } });
|
||||
return result.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }));
|
||||
});
|
||||
expect(violations).toEqual([]);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath(`password-required-${language}-${theme}.png`), fullPage: true });
|
||||
});
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.46",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.46",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.46",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.46",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.25",
|
||||
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-addresses.git#v0.1.22",
|
||||
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-addresses.git#v0.1.23",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.23",
|
||||
"@govoplan/approvals-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-approvals.git#v0.1.21",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.23",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.28",
|
||||
"@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.24",
|
||||
"@govoplan/committee-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-committee.git#v0.1.21",
|
||||
"@govoplan/connectors-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-connectors.git#v0.1.26",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.24",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.29",
|
||||
"@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.25",
|
||||
"@govoplan/committee-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-committee.git#v0.1.22",
|
||||
"@govoplan/connectors-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-connectors.git#v0.1.27",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20",
|
||||
"@govoplan/dataflow-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dataflow.git#v0.1.24",
|
||||
"@govoplan/datasources-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-datasources.git#v0.1.25",
|
||||
"@govoplan/dataflow-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dataflow.git#v0.1.25",
|
||||
"@govoplan/datasources-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-datasources.git#v0.1.26",
|
||||
"@govoplan/dist-lists-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dist-lists.git#v0.1.21",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.23",
|
||||
"@govoplan/encryption-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-encryption.git#v0.1.20",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.26",
|
||||
"@govoplan/forms-runtime-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-forms-runtime.git#v0.1.21",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.27",
|
||||
"@govoplan/forms-runtime-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-forms-runtime.git#v0.1.22",
|
||||
"@govoplan/forms-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-forms.git#v0.1.23",
|
||||
"@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.21",
|
||||
"@govoplan/identity-trust-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity-trust.git#v0.1.21",
|
||||
"@govoplan/identity-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git#v0.1.21",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.25",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.27",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.26",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.28",
|
||||
"@govoplan/notifications-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-notifications.git#v0.1.20",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.22",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.21",
|
||||
@@ -42,14 +42,14 @@
|
||||
"@govoplan/projects-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-projects.git#v0.1.20",
|
||||
"@govoplan/quick-access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-quick-access.git#v0.1.21",
|
||||
"@govoplan/records-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-records.git#v0.1.24",
|
||||
"@govoplan/reporting-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-reporting.git#v0.1.21",
|
||||
"@govoplan/reporting-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-reporting.git#v0.1.22",
|
||||
"@govoplan/risk-compliance-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-risk-compliance.git#v0.1.21",
|
||||
"@govoplan/scheduling-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-scheduling.git#v0.1.22",
|
||||
"@govoplan/search-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-search.git#v0.1.20",
|
||||
"@govoplan/tasks-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tasks.git#v0.1.23",
|
||||
"@govoplan/templates-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-templates.git#v0.1.22",
|
||||
"@govoplan/tenancy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git#v0.1.22",
|
||||
"@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.22",
|
||||
"@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.23",
|
||||
"@govoplan/views-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-views.git#v0.1.22",
|
||||
"@govoplan/voting-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-voting.git#v0.1.21",
|
||||
"@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.22",
|
||||
@@ -807,8 +807,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/addresses-webui": {
|
||||
"version": "0.1.22",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-addresses.git#53490e7be780e0b378b5aff3c929e5de59b5e44a",
|
||||
"version": "0.1.23",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-addresses.git#d4fa024034e52924e9573b13a46dd0cee2287d9e",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -870,8 +870,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/calendar-webui": {
|
||||
"version": "0.1.23",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#3e5fc05ca3728464131067d1fa1bc25befeae939",
|
||||
"version": "0.1.24",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#8e36f8b3d203fc0b08ea0487540fb7ac597cb8c9",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.44",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -889,8 +889,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/campaign-webui": {
|
||||
"version": "0.1.28",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#c51fc180fba75441dbd19c25b02a3ddc37c81a5d",
|
||||
"version": "0.1.29",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#19437ce378553f6ab559e3c0cba44150e495ef52",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
@@ -922,8 +922,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/cases-webui": {
|
||||
"version": "0.1.24",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#afda3a5ac5f05898dfbc4daa91a74fd50d59f9c1",
|
||||
"version": "0.1.25",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#0337e0cf0b37a9b89dd51cccf28500649361fb37",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.30",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -938,8 +938,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/committee-webui": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-committee.git#7c2ec4ec17e650699164d99a656ba5e48068fdf7",
|
||||
"version": "0.1.22",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-committee.git#96c48a7f2d3a8fa36f44d5806a14003e40cc4807",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -954,8 +954,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/connectors-webui": {
|
||||
"version": "0.1.26",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-connectors.git#33de5cac5584ad41319a447f12d1eb5dbb11f1d7",
|
||||
"version": "0.1.27",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-connectors.git#889cafaf207a2e7e495c64ccbac07d9faf5a5dce",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -984,10 +984,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/dataflow-webui": {
|
||||
"version": "0.1.24",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dataflow.git#618f10fe89223b8acbda080047f3c2ed39dae67a",
|
||||
"version": "0.1.25",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dataflow.git#a6c5bab3a4b7a52cb0f9b07911216ce61f362bbe",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"@govoplan/core-webui": "^0.1.46",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -1002,10 +1002,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/datasources-webui": {
|
||||
"version": "0.1.25",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-datasources.git#8b8c6c548ecd2801533b9c7d5003f13f3ca41f11",
|
||||
"version": "0.1.26",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-datasources.git#9d067f1baded6e65a221fbba0b46fdd953404b51",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"@govoplan/core-webui": "^0.1.46",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -1070,8 +1070,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/files-webui": {
|
||||
"version": "0.1.26",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#ff84812f7f15bab4385fdcbed8605a7cc1de8195",
|
||||
"version": "0.1.27",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#61625fb00fedbd4474aab1854ba9cedaf5fd18f3",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -1089,8 +1089,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/forms-runtime-webui": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-forms-runtime.git#cd78800a6561a5cc1d04dc9430bd03a9466ea6db",
|
||||
"version": "0.1.22",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-forms-runtime.git#1236b861b8128b9fa1ce30c51a32856554d8c3df",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -1167,8 +1167,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/idm-webui": {
|
||||
"version": "0.1.25",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#46e09d0c68866eaf7cf46ccfe773854eac0d0b0b",
|
||||
"version": "0.1.26",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#ba04593e29140298687a29a6bdf46d1376974c02",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -1186,8 +1186,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/mail-webui": {
|
||||
"version": "0.1.27",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#480c18c67cc345b462ec9dd0dde252fbfc13f571",
|
||||
"version": "0.1.28",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#d9262982147ff70b4edbc8990d0bf8ccb5f190a8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -1371,8 +1371,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/reporting-webui": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-reporting.git#89755e1924e52a9f4ee225e5699b9e446adbcd30",
|
||||
"version": "0.1.22",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-reporting.git#8253bd0dd6fe93b739b7482a04c058f2bed3aff4",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -1486,8 +1486,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/tickets-webui": {
|
||||
"version": "0.1.22",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#0ad2ef96b43a83ea7e0e9188487392db735b4a85",
|
||||
"version": "0.1.23",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#2be4a0c598a32a5ce044320f2a1311a192b9d5e3",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.30",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -1618,6 +1618,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1734,6 +1737,9 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1747,6 +1753,9 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1760,6 +1769,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1773,6 +1785,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1786,6 +1801,9 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1799,6 +1817,9 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1812,6 +1833,9 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1825,6 +1849,9 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1838,6 +1865,9 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1851,6 +1881,9 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1864,6 +1897,9 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1877,6 +1913,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1890,6 +1929,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2840,9 +2882,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.422",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz",
|
||||
"integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==",
|
||||
"version": "1.5.423",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.423.tgz",
|
||||
"integrity": "sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
|
||||
+20
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.46",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -43,26 +43,29 @@
|
||||
"test:core-interface-patterns": "node scripts/test-core-interface-patterns.mjs",
|
||||
"test:vite-cache-isolation": "node scripts/test-vite-cache-isolation.mjs",
|
||||
"test:api-client-cache": "node --test tests/api-client-cache.test.mjs",
|
||||
"test:auth-action-state": "node --test tests/auth-action-state.test.mjs",
|
||||
"test:dependency-security": "node --test tests/dependency-security.test.mjs",
|
||||
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.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:layout-primitives": "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 && printf 'module.exports = {};\\n' > .component-test-build/src/components/ProductAvailabilityState.css && node .component-test-build/tests/layout-primitives.test.js",
|
||||
"test:data-grid-actions": "node scripts/run-component-tests.mjs data-grid-actions",
|
||||
"test:dialog-focus": "node scripts/run-component-tests.mjs dialog-focus",
|
||||
"test:explorer-tree": "node scripts/run-component-tests.mjs explorer-tree",
|
||||
"test:icon-button": "node scripts/run-component-tests.mjs icon-button",
|
||||
"test:layout-primitives": "node scripts/run-component-tests.mjs layout-primitives",
|
||||
"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/module-loading.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/launch-context.test.js && node .module-test-build/tests/definition-graph.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:metric-card": "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/metric-card.test.js",
|
||||
"test:page-layout": "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/page-layout.test.js",
|
||||
"test:workspace-layout": "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/workspace-layout.test.js",
|
||||
"test:people-picker": "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/people-picker.test.js",
|
||||
"test:password-field": "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/password-generator.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:action-blocker": "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/action-blocker-hint.test.js",
|
||||
"test:documentation-help": "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/documentation-help-link.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",
|
||||
"test:wysiwyg-editor": "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/wysiwyg-editor-utils.test.js"
|
||||
"test:mail-components": "node scripts/run-component-tests.mjs mail-components",
|
||||
"test:metric-card": "node scripts/run-component-tests.mjs metric-card",
|
||||
"test:page-layout": "node scripts/run-component-tests.mjs page-layout",
|
||||
"test:workspace-layout": "node scripts/run-component-tests.mjs workspace-layout",
|
||||
"test:people-picker": "node scripts/run-component-tests.mjs people-picker",
|
||||
"test:password-field": "node scripts/run-component-tests.mjs password-field",
|
||||
"test:resource-access": "node scripts/run-component-tests.mjs resource-access",
|
||||
"test:action-blocker": "node scripts/run-component-tests.mjs action-blocker",
|
||||
"test:documentation-help": "node scripts/run-component-tests.mjs documentation-help",
|
||||
"test:selection-list": "node scripts/run-component-tests.mjs selection-list",
|
||||
"test:wysiwyg-editor": "node scripts/run-component-tests.mjs wysiwyg-editor",
|
||||
"test:components": "node scripts/run-component-tests.mjs",
|
||||
"test:component-runner": "node --test tests/component-test-runner.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
|
||||
+15
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.46",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -35,29 +35,29 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.25",
|
||||
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-addresses.git#v0.1.22",
|
||||
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-addresses.git#v0.1.23",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.23",
|
||||
"@govoplan/approvals-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-approvals.git#v0.1.21",
|
||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.23",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.28",
|
||||
"@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.24",
|
||||
"@govoplan/committee-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-committee.git#v0.1.21",
|
||||
"@govoplan/connectors-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-connectors.git#v0.1.26",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.24",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.29",
|
||||
"@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.25",
|
||||
"@govoplan/committee-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-committee.git#v0.1.22",
|
||||
"@govoplan/connectors-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-connectors.git#v0.1.27",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20",
|
||||
"@govoplan/dataflow-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dataflow.git#v0.1.24",
|
||||
"@govoplan/datasources-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-datasources.git#v0.1.25",
|
||||
"@govoplan/dataflow-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dataflow.git#v0.1.25",
|
||||
"@govoplan/datasources-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-datasources.git#v0.1.26",
|
||||
"@govoplan/dist-lists-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dist-lists.git#v0.1.21",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.23",
|
||||
"@govoplan/encryption-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-encryption.git#v0.1.20",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.26",
|
||||
"@govoplan/forms-runtime-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-forms-runtime.git#v0.1.21",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.27",
|
||||
"@govoplan/forms-runtime-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-forms-runtime.git#v0.1.22",
|
||||
"@govoplan/forms-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-forms.git#v0.1.23",
|
||||
"@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.21",
|
||||
"@govoplan/identity-trust-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity-trust.git#v0.1.21",
|
||||
"@govoplan/identity-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git#v0.1.21",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.25",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.27",
|
||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.26",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.28",
|
||||
"@govoplan/notifications-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-notifications.git#v0.1.20",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.22",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.21",
|
||||
@@ -68,14 +68,14 @@
|
||||
"@govoplan/projects-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-projects.git#v0.1.20",
|
||||
"@govoplan/quick-access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-quick-access.git#v0.1.21",
|
||||
"@govoplan/records-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-records.git#v0.1.24",
|
||||
"@govoplan/reporting-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-reporting.git#v0.1.21",
|
||||
"@govoplan/reporting-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-reporting.git#v0.1.22",
|
||||
"@govoplan/risk-compliance-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-risk-compliance.git#v0.1.21",
|
||||
"@govoplan/scheduling-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-scheduling.git#v0.1.22",
|
||||
"@govoplan/search-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-search.git#v0.1.20",
|
||||
"@govoplan/tasks-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tasks.git#v0.1.23",
|
||||
"@govoplan/templates-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-templates.git#v0.1.22",
|
||||
"@govoplan/tenancy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git#v0.1.22",
|
||||
"@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.22",
|
||||
"@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.23",
|
||||
"@govoplan/views-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-views.git#v0.1.22",
|
||||
"@govoplan/voting-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-voting.git#v0.1.21",
|
||||
"@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.22",
|
||||
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
// Compile the shared component contract once per invocation. Each invocation
|
||||
// owns its output, so standalone aliases and concurrent agents cannot erase it.
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const componentSuites = Object.freeze({
|
||||
"data-grid-actions": ["data-grid-actions", "data-grid-sizing"],
|
||||
"dialog-focus": ["dialog-focus"],
|
||||
"explorer-tree": ["explorer-tree"],
|
||||
"icon-button": ["icon-button"],
|
||||
"layout-primitives": ["layout-primitives"],
|
||||
"mail-components": ["mail-components"],
|
||||
"metric-card": ["metric-card"],
|
||||
"page-layout": ["page-layout"],
|
||||
"workspace-layout": ["workspace-layout"],
|
||||
"people-picker": ["people-picker"],
|
||||
"password-field": ["password-generator"],
|
||||
"resource-access": ["resource-access-explanation"],
|
||||
"action-blocker": ["action-blocker-hint"],
|
||||
"documentation-help": ["documentation-help-link"],
|
||||
"selection-list": ["selection-list"],
|
||||
"wysiwyg-editor": ["wysiwyg-editor-utils"],
|
||||
});
|
||||
|
||||
export function selectSuites(names) {
|
||||
const selected = [...new Set(names.filter((name) => name !== "--"))];
|
||||
if (!selected.length || (selected.length === 1 && selected[0] === "all")) return Object.keys(componentSuites);
|
||||
for (const name of selected) {
|
||||
if (!Object.hasOwn(componentSuites, name)) throw new Error(`Unknown component suite: ${name}`);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function execute(argv, { cwd, signal }) {
|
||||
if (signal?.aborted) return Promise.reject(new Error("Component tests interrupted"));
|
||||
return new Promise((resolveCommand, reject) => {
|
||||
const child = spawn(argv[0], argv.slice(1), { cwd, stdio: "inherit", shell: false });
|
||||
let killTimer;
|
||||
const abort = () => {
|
||||
child.kill("SIGTERM");
|
||||
killTimer = setTimeout(() => child.kill("SIGKILL"), 5000);
|
||||
killTimer.unref();
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
child.once("error", reject);
|
||||
child.once("close", (code, childSignal) => {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
clearTimeout(killTimer);
|
||||
if (code === 0 && !signal?.aborted) resolveCommand();
|
||||
else reject(new Error(`Component command failed (${childSignal ?? code}): ${argv.slice(1).join(" ")}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function runComponentTests({
|
||||
names = [],
|
||||
webuiRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."),
|
||||
run = execute,
|
||||
compiler,
|
||||
signal,
|
||||
} = {}) {
|
||||
const selected = selectSuites(names);
|
||||
const require = createRequire(join(webuiRoot, "package.json"));
|
||||
const typescript = compiler ?? require.resolve("typescript/bin/tsc");
|
||||
const output = mkdtempSync(join(webuiRoot, ".component-test-build-"));
|
||||
try {
|
||||
writeFileSync(join(output, "package.json"), '{"type":"commonjs"}\n');
|
||||
await run([process.execPath, typescript, "-p", "tsconfig.component-tests.json", "--outDir", output], { cwd: webuiRoot, signal });
|
||||
// The SSR tests intentionally do not load browser CSS.
|
||||
mkdirSync(join(output, "src", "components"), { recursive: true });
|
||||
writeFileSync(join(output, "src", "components", "ProductAvailabilityState.css"), "module.exports = {};\n");
|
||||
for (const name of selected) {
|
||||
for (const test of componentSuites[name]) {
|
||||
await run([process.execPath, join(output, "tests", `${test}.test.js`)], { cwd: webuiRoot, signal });
|
||||
}
|
||||
if (name === "dialog-focus") {
|
||||
await run([process.execPath, join(webuiRoot, "scripts", "test-dialog-focus-structure.mjs")], { cwd: webuiRoot, signal });
|
||||
}
|
||||
}
|
||||
return { suites: selected, compiled: 1 };
|
||||
} finally {
|
||||
// Only remove this invocation's freshly-created directory, never the
|
||||
// legacy shared build or another invocation's artifacts.
|
||||
rmSync(output, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const controller = new AbortController();
|
||||
let interrupted;
|
||||
const stop = (signal) => { interrupted = signal; controller.abort(); };
|
||||
const onInterrupt = () => stop("SIGINT");
|
||||
const onTerminate = () => stop("SIGTERM");
|
||||
process.on("SIGINT", onInterrupt);
|
||||
process.on("SIGTERM", onTerminate);
|
||||
try {
|
||||
const result = await runComponentTests({ names: process.argv.slice(2), signal: controller.signal });
|
||||
process.stdout.write(`Component suites passed: ${result.suites.length}; compilations: ${result.compiled}.\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exitCode = interrupted === "SIGINT" ? 130 : interrupted === "SIGTERM" ? 143 : 1;
|
||||
} finally {
|
||||
process.removeListener("SIGINT", onInterrupt);
|
||||
process.removeListener("SIGTERM", onTerminate);
|
||||
}
|
||||
}
|
||||
+62
-33
@@ -1,6 +1,8 @@
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router";
|
||||
import { lazy, useEffect, useMemo, useState } from "react";
|
||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||
import { fetchSession, fetchShellAuth, logout, updateProfile } from "./api/auth";
|
||||
import { createModuleRefresh } from "./platform/moduleRefresh";
|
||||
import type { AuthActionUiCapability } from "./types";
|
||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { AUTH_REQUIRED_EVENT, apiSettingsForAuthUpdate, clearApiReadCache, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPalette, UserUiPreferences, ViewsRuntimeUiCapability } from "./types";
|
||||
@@ -34,6 +36,7 @@ import { applyAppearanceOverrides } from "./components/appearanceOverrides";
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
const ProductSurfaceRoute = lazy(() => import("./components/ProductSurfaceRoute"));
|
||||
const AuthActionGate = lazy(() => import("./features/auth/AuthActionGate"));
|
||||
|
||||
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
||||
compact_tables: false,
|
||||
@@ -69,6 +72,11 @@ export default function App() {
|
||||
|
||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
||||
const requiredAuthAction = auth?.user.required_auth_action ?? null;
|
||||
const authActions = useMemo(
|
||||
() => uiCapability<AuthActionUiCapability>("auth.actions", publicWebModules),
|
||||
[publicWebModules]
|
||||
);
|
||||
const viewsRuntime = useMemo(
|
||||
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
|
||||
[webModules]
|
||||
@@ -83,7 +91,7 @@ export default function App() {
|
||||
);
|
||||
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
||||
const contextModules = auth ? webModules : publicWebModules;
|
||||
const contextModules = auth && !requiredAuthAction ? webModules : publicWebModules;
|
||||
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||
|
||||
@@ -102,7 +110,7 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth || !viewsRuntime) {
|
||||
if (!auth || requiredAuthAction || !viewsRuntime) {
|
||||
setBaseViewProjection(null);
|
||||
setWorkflowViewProjection(null);
|
||||
return;
|
||||
@@ -137,11 +145,12 @@ export default function App() {
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey,
|
||||
requiredAuthAction,
|
||||
viewsRuntime
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth || !viewsRuntime) {
|
||||
if (!auth || requiredAuthAction || !viewsRuntime) {
|
||||
setWorkflowViewProjection(null);
|
||||
return;
|
||||
}
|
||||
@@ -167,6 +176,7 @@ export default function App() {
|
||||
auth?.user?.id,
|
||||
auth?.active_tenant?.id,
|
||||
auth?.tenant.id,
|
||||
requiredAuthAction,
|
||||
viewsRuntime
|
||||
]);
|
||||
|
||||
@@ -289,52 +299,44 @@ export default function App() {
|
||||
}, [settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth) return;
|
||||
|
||||
let cancelled = false;
|
||||
let inFlight = false;
|
||||
let lastRefreshAt = 0;
|
||||
function loadModules() {
|
||||
inFlight = true;
|
||||
lastRefreshAt = Date.now();
|
||||
return fetchPlatformModules(settings).
|
||||
then((response) => {if (!cancelled) setPlatformModules(response.modules);}).
|
||||
catch(() => {
|
||||
if (!cancelled) {
|
||||
setPlatformModules(null);
|
||||
setWebModulesLoading(false);
|
||||
}
|
||||
}).
|
||||
finally(() => {inFlight = false;});
|
||||
if (!auth || requiredAuthAction) {
|
||||
setPlatformModules(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadModules();
|
||||
const refresh = createModuleRefresh({
|
||||
load: () => fetchPlatformModules(settings),
|
||||
accept: (response) => setPlatformModules(response.modules),
|
||||
reject: () => {
|
||||
setPlatformModules(null);
|
||||
setWebModulesLoading(false);
|
||||
}
|
||||
});
|
||||
refresh.invalidate();
|
||||
|
||||
function handleModulesChanged() {
|
||||
void loadModules();
|
||||
refresh.invalidate();
|
||||
}
|
||||
|
||||
function refreshVisibleModules() {
|
||||
if (document.visibilityState === "hidden" || inFlight) return;
|
||||
if (Date.now() - lastRefreshAt < 5_000) return;
|
||||
void loadModules();
|
||||
refresh.refreshVisible(document.visibilityState !== "hidden");
|
||||
}
|
||||
|
||||
window.addEventListener(PLATFORM_MODULES_CHANGED_EVENT, handleModulesChanged);
|
||||
window.addEventListener("focus", refreshVisibleModules);
|
||||
document.addEventListener("visibilitychange", refreshVisibleModules);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
refresh.dispose();
|
||||
window.removeEventListener(PLATFORM_MODULES_CHANGED_EVENT, handleModulesChanged);
|
||||
window.removeEventListener("focus", refreshVisibleModules);
|
||||
document.removeEventListener("visibilitychange", refreshVisibleModules);
|
||||
};
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
|
||||
}, [auth, requiredAuthAction, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setWebModuleLoadFailures([]);
|
||||
if (!auth) {
|
||||
if (!auth || requiredAuthAction) {
|
||||
setLocalWebModules([]);
|
||||
setRemoteWebModules([]);
|
||||
setWebModulesLoading(false);
|
||||
@@ -374,7 +376,7 @@ export default function App() {
|
||||
}
|
||||
});
|
||||
return () => {cancelled = true;};
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules]);
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, requiredAuthAction, platformModules]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -489,7 +491,7 @@ export default function App() {
|
||||
window.removeEventListener("focus", refreshVisibleSession);
|
||||
document.removeEventListener("visibilitychange", refreshVisibleSession);
|
||||
};
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, auth?.principal?.session_id, requiredAuthAction, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
if (checkingSession) {
|
||||
return (
|
||||
@@ -540,6 +542,27 @@ export default function App() {
|
||||
|
||||
}
|
||||
|
||||
if (requiredAuthAction) {
|
||||
return <PlatformLanguageProvider
|
||||
systemAvailableLanguages={systemLanguages?.available}
|
||||
systemEnabledLanguageCodes={systemLanguages?.enabled}
|
||||
defaultLanguage={systemLanguages?.defaultLanguage}
|
||||
preferredLanguageCode={auth.user.preferred_language ?? undefined}
|
||||
moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
||||
<ModuleLoadBoundary resetKey={requiredAuthAction}>
|
||||
<AuthActionGate settings={settings} auth={auth} capability={authActions}
|
||||
onAuthChange={updateAuth}
|
||||
onSignOut={() => { void logout(settings).catch(() => undefined).finally(() => updateAuth(null, "")); }} />
|
||||
</ModuleLoadBoundary>
|
||||
{reloginMessage && <LoginModal settings={settings} message={reloginMessage}
|
||||
onClose={() => setReloginMessage("")} onLogin={handleRelogin} />}
|
||||
</PlatformViewProvider>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
|
||||
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
|
||||
const localDocsAvailable = hasAnyScope(auth, ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"]) &&
|
||||
webModules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
|
||||
@@ -657,7 +680,7 @@ function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayl
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAuthInfo(response: AuthPayload): AuthInfo {
|
||||
export function normalizeAuthInfo(response: AuthPayload): AuthInfo {
|
||||
const principal = response.principal ?? null;
|
||||
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
|
||||
const user = normalizeAuthUser(response.user, principal);
|
||||
@@ -710,6 +733,8 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
|
||||
tenant_display_name: user.tenant_display_name ?? null,
|
||||
is_tenant_admin: user.is_tenant_admin ?? false,
|
||||
password_reset_required: user.password_reset_required ?? false,
|
||||
required_auth_action: user.required_auth_action ?? null,
|
||||
local_password: user.local_password ?? false,
|
||||
preferred_language: user.preferred_language ?? null,
|
||||
enabled_language_codes: user.enabled_language_codes ?? [],
|
||||
ui_preferences: normalizeUiPreferences(user.ui_preferences)
|
||||
@@ -728,16 +753,20 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
|
||||
tenant_display_name: principal.display_name ?? null,
|
||||
is_tenant_admin: false,
|
||||
password_reset_required: false,
|
||||
required_auth_action: null,
|
||||
local_password: false,
|
||||
preferred_language: null,
|
||||
enabled_language_codes: [],
|
||||
ui_preferences: DEFAULT_UI_PREFERENCES
|
||||
};
|
||||
}
|
||||
|
||||
function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
|
||||
export function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
|
||||
const activeTenant = auth.active_tenant ?? auth.tenant;
|
||||
if (sessionInfo.user.id !== auth.user.id) return false;
|
||||
if (sessionInfo.user.account_id !== auth.user.account_id) return false;
|
||||
if ((sessionInfo.user.required_auth_action ?? null) !== (auth.user.required_auth_action ?? null)) return false;
|
||||
if (Boolean(sessionInfo.user.local_password) !== Boolean(auth.user.local_password)) return false;
|
||||
if ((sessionInfo.active_tenant ?? sessionInfo.tenant).id !== activeTenant.id) return false;
|
||||
if (auth.principal?.auth_method && sessionInfo.auth_method !== auth.principal.auth_method) return false;
|
||||
if (auth.principal?.session_id && sessionInfo.session_id && auth.principal.session_id !== sessionInfo.session_id) return false;
|
||||
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
import type { ApiSettings, AuthInfo } from "../types";
|
||||
|
||||
/** Stable in-memory async-work fence, not an authorization decision or cache.
|
||||
* Includes credentials: never persist, log, display or send this key. Cosmetic
|
||||
* profile refreshes must not invalidate a server-accepted mutation.
|
||||
*/
|
||||
export function authAuthorityKey(auth: AuthInfo | null | undefined, settings: ApiSettings): string {
|
||||
const set = (values: readonly string[] | null | undefined) => [...new Set(values ?? [])].sort();
|
||||
const principal = auth?.principal;
|
||||
return JSON.stringify([
|
||||
settings.apiBaseUrl, settings.apiKey, settings.accessToken,
|
||||
auth ? [
|
||||
auth.user.id, auth.user.account_id, auth.user.email,
|
||||
Boolean(auth.user.is_tenant_admin), Boolean(auth.user.password_reset_required),
|
||||
auth.user.required_auth_action ?? null, Boolean(auth.user.local_password),
|
||||
auth.tenant.id, auth.tenant.is_active !== false,
|
||||
(auth.active_tenant ?? auth.tenant).id, (auth.active_tenant ?? auth.tenant).is_active !== false,
|
||||
set(auth.scopes),
|
||||
(auth.roles ?? []).map((role) => JSON.stringify([role.id, role.slug, role.level ?? null, set(role.permissions)])).sort(),
|
||||
set((auth.groups ?? []).map((group) => group.id)),
|
||||
principal ? [
|
||||
principal.account_id, principal.membership_id ?? null, principal.tenant_id ?? null,
|
||||
principal.identity_id ?? null, principal.auth_method, principal.api_key_id ?? null,
|
||||
principal.session_id ?? null, principal.service_account_id ?? null,
|
||||
principal.acting_assignment_id ?? null, principal.acting_for_account_id ?? null,
|
||||
principal.email ?? null, set(principal.scopes), set(principal.group_ids),
|
||||
set(principal.role_ids), set(principal.function_assignment_ids), set(principal.delegation_ids)
|
||||
] : null
|
||||
] : null
|
||||
]);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
||||
import AdvancedOptionsPanel from "./AdvancedOptionsPanel";
|
||||
import DocumentationHelpLink from "./help/DocumentationHelpLink";
|
||||
import type { DocumentationHelpReference } from "./help/documentationHelp";
|
||||
import TextWithHelp from "./help/TextWithHelp";
|
||||
|
||||
export type ActionBlockerReason = {
|
||||
summary: ReactNode;
|
||||
@@ -46,7 +47,9 @@ export default function ActionBlockerHint({
|
||||
<section className={joinClasses("action-blocker-hint", `tone-${tone}`, className)}>
|
||||
<Icon className="action-blocker-icon" size={18} aria-hidden="true" />
|
||||
<div className="action-blocker-copy">
|
||||
<strong>{reason.summary}</strong>
|
||||
<TextWithHelp help={documentation && <DocumentationHelpLink reference={documentation} />}>
|
||||
<strong>{reason.summary}</strong>
|
||||
</TextWithHelp>
|
||||
{reason.details && <p>{reason.details}</p>}
|
||||
{hasActionRows && (
|
||||
<dl>
|
||||
@@ -75,7 +78,6 @@ export default function ActionBlockerHint({
|
||||
<div>{reason.technicalDetails}</div>
|
||||
</AdvancedOptionsPanel>
|
||||
)}
|
||||
{documentation && <DocumentationHelpLink reference={documentation} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useEffect, useState, type HTMLAttributes, type ReactNode } from "react"
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
import TextWithHelp from "./help/TextWithHelp";
|
||||
|
||||
export type CardProps = PlatformInterfaceIdentityProps & Omit<HTMLAttributes<HTMLElement>, "children" | "title"> & {
|
||||
title?: ReactNode;
|
||||
titleHelp?: ReactNode;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
afterBody?: ReactNode;
|
||||
@@ -48,6 +50,7 @@ function writeCollapseState(storageKey: string | null, collapsed: boolean): void
|
||||
|
||||
export default function Card({
|
||||
title,
|
||||
titleHelp,
|
||||
children,
|
||||
actions,
|
||||
afterBody,
|
||||
@@ -99,7 +102,9 @@ export default function Card({
|
||||
>
|
||||
{hasHeader &&
|
||||
<header className={["card-header", headerClassName].filter(Boolean).join(" ")}>
|
||||
{title && (typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>)}
|
||||
{title && <TextWithHelp as="div" className="card-title-with-help" data-help-anchor="title" help={titleHelp}>
|
||||
{typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>}
|
||||
</TextWithHelp>}
|
||||
{(actions || collapsible) &&
|
||||
<div className={["card-actions", actionsClassName].filter(Boolean).join(" ")}>
|
||||
{actions}
|
||||
|
||||
@@ -450,11 +450,11 @@ export default function CredentialEnvelopeManager({
|
||||
)}
|
||||
<Card
|
||||
title={title}
|
||||
titleHelp={<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
|
||||
helpContextId="access.credentials"
|
||||
helpModuleId="access"
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
|
||||
<Button
|
||||
type="button"
|
||||
title="Reload credentials"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./dialogStack";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
import { DialogActions, type DialogActionAlignment } from "./DialogAnatomy";
|
||||
import TextWithHelp from "./help/TextWithHelp";
|
||||
|
||||
export type DialogSize = "small" | "default" | "large" | "wide" | "full";
|
||||
export type DialogVariant = "default" | "administration";
|
||||
@@ -18,6 +19,7 @@ export type DialogBodyPadding = "none" | "compact" | "default";
|
||||
export type DialogProps = PlatformInterfaceIdentityProps & {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
titleHelp?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
description?: ReactNode;
|
||||
@@ -51,6 +53,7 @@ function joinClasses(...classes: Array<string | undefined | false>) {
|
||||
export default function Dialog({
|
||||
open,
|
||||
title,
|
||||
titleHelp,
|
||||
children,
|
||||
footer,
|
||||
description,
|
||||
@@ -168,7 +171,9 @@ export default function Dialog({
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
<div className={joinClasses("dialog-header", headerClassName)}>
|
||||
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{renderedTitle}</h2>
|
||||
<TextWithHelp as="div" className="dialog-title-with-help" data-help-anchor="title" help={titleHelp}>
|
||||
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{renderedTitle}</h2>
|
||||
</TextWithHelp>
|
||||
{showCloseButton && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function NavigationPreferenceEditor({ items, productAreas = [], v
|
||||
const instructionsId = useId();
|
||||
const inherited = inheritedNavigationLayout(items, scope, productAreas);
|
||||
const editable = materializeNavigationLayout(value, inherited);
|
||||
const layoutStatus = value === null ? "inherited" : (editable.separators?.length ?? 0) > 0 ? "grouped" : "flat";
|
||||
const byId = new Map(items.map((item) => [navigationId(item), item]));
|
||||
const inheritedScope = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
|
||||
const ancestorLocked = (id: string) => Boolean(byId.get(id)?.navigationLayers?.[inheritedScope]?.locked);
|
||||
@@ -104,6 +105,9 @@ export default function NavigationPreferenceEditor({ items, productAreas = [], v
|
||||
<p className="muted small-note" id={instructionsId}>{navigationText("help")}</p>
|
||||
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>{navigationText("inherit")}</Button>
|
||||
</ActionToolbar>
|
||||
<p className="muted small-note" data-navigation-layout-status={layoutStatus}>
|
||||
{navigationText(`${layoutStatus}_status`)}
|
||||
</p>
|
||||
<ActionToolbar className="navigation-preference-add">
|
||||
<select aria-label={translateText(navigationText("available"))} value={addId} disabled={disabled || available.length === 0} onChange={(event) => setSelectedModule(event.target.value)}>
|
||||
{available.length === 0 && <option value="">{translateText(navigationText("all_added"))}</option>}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
import ActionToolbar, { ToolbarGroup, type ActionToolbarDensity, type ActionToolbarSurface } from "./ActionToolbar";
|
||||
import Button from "./Button";
|
||||
import { useUnsavedChanges } from "./UnsavedChangesContext";
|
||||
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import TextWithHelp from "./help/TextWithHelp";
|
||||
|
||||
export type PageReloadAction = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick"> & PlatformInterfaceIdentityProps & {
|
||||
onReload: () => void;
|
||||
@@ -25,8 +27,13 @@ type RefreshablePageActions = {
|
||||
};
|
||||
|
||||
type PageActionBarCommonProps = PlatformInterfaceIdentityProps &
|
||||
Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
||||
Omit<HTMLAttributes<HTMLDivElement>, "children" | "title"> & {
|
||||
/** A visible context heading for workspaces without a separate page header. */
|
||||
title?: ReactNode;
|
||||
titleHelp?: ReactNode;
|
||||
titleLevel?: 1 | 2 | 3;
|
||||
contextActions?: ReactNode;
|
||||
/** Non-documentation help actions; documentation belongs beside a title. */
|
||||
helpAction?: ReactNode;
|
||||
label?: string;
|
||||
};
|
||||
@@ -66,7 +73,8 @@ export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePag
|
||||
cleanDisabledReason?: ReactNode;
|
||||
savingDisabledReason?: ReactNode;
|
||||
invalidDisabledReason?: ReactNode;
|
||||
discardAction: PageEditorAction;
|
||||
/** Exit/cancel also works for a clean draft; reset/discard requires changes. */
|
||||
discardAction: PageEditorAction & { behavior?: "reset" | "exit" };
|
||||
saveAction: PageEditorAction;
|
||||
primaryActions?: ReactNode;
|
||||
destructiveActions?: ReactNode;
|
||||
@@ -178,6 +186,7 @@ function EditorAction({
|
||||
* actions, wording, permissions, blockers, and consequences placed in them.
|
||||
*/
|
||||
export default function PageActionBar(props: PageActionBarProps & SemanticActionBarPresentation) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const normalizedProps = props as PageActionBarProps & SemanticActionBarPresentation & {
|
||||
createAction?: ReactNode;
|
||||
primaryActions?: ReactNode;
|
||||
@@ -192,7 +201,7 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
|
||||
cleanDisabledReason?: ReactNode;
|
||||
savingDisabledReason?: ReactNode;
|
||||
invalidDisabledReason?: ReactNode;
|
||||
discardAction?: PageEditorAction;
|
||||
discardAction?: PageEditorAction & { behavior?: "reset" | "exit" };
|
||||
saveAction?: PageEditorAction;
|
||||
};
|
||||
const {
|
||||
@@ -202,6 +211,9 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
|
||||
surface,
|
||||
refreshable = false,
|
||||
reloadAction,
|
||||
title,
|
||||
titleHelp,
|
||||
titleLevel = 2,
|
||||
contextActions,
|
||||
helpAction,
|
||||
createAction,
|
||||
@@ -227,6 +239,7 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
|
||||
helpTopicId,
|
||||
...toolbarProps
|
||||
} = normalizedProps;
|
||||
const TitleElement = titleLevel === 1 ? "h1" : titleLevel === 3 ? "h3" : "h2";
|
||||
|
||||
let trailingActions: ReactNode;
|
||||
if (variant === "overview") {
|
||||
@@ -241,7 +254,10 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
|
||||
</>
|
||||
);
|
||||
} else if (variant === "editor") {
|
||||
const discardDisabledReason = state === "saving" ? savingDisabledReason : state === "clean" ? cleanDisabledReason : undefined;
|
||||
const { behavior: discardBehavior = "reset", ...discardButtonAction } = discardAction!;
|
||||
const discardDisabledReason = state === "saving"
|
||||
? savingDisabledReason
|
||||
: state === "clean" && discardBehavior !== "exit" ? cleanDisabledReason : undefined;
|
||||
const saveDisabledReason = state === "saving"
|
||||
? savingDisabledReason
|
||||
: state === "clean"
|
||||
@@ -254,7 +270,7 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
|
||||
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
|
||||
<DestructiveSlot>{destructiveActions}</DestructiveSlot>
|
||||
<ActionSlot name="discard">
|
||||
<EditorAction action={discardAction!} variant="ghost" disabledReason={discardDisabledReason} />
|
||||
<EditorAction action={discardButtonAction} variant="ghost" disabledReason={discardDisabledReason} />
|
||||
</ActionSlot>
|
||||
<ActionSlot name="save">
|
||||
<EditorAction action={saveAction!} variant="primary" disabledReason={saveDisabledReason} />
|
||||
@@ -291,8 +307,11 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
|
||||
: undefined}
|
||||
data-page-dirty={variant === "editor" ? (state === "clean" ? "false" : "true") : undefined}
|
||||
>
|
||||
{contextActions ? <ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
|
||||
<ActionSlot name="context">{contextActions}</ActionSlot>
|
||||
{title || contextActions ? <ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
|
||||
{title && <TextWithHelp as="div" className="page-action-bar-title" data-help-anchor="title" help={titleHelp}>
|
||||
<TitleElement>{translateReactNode(title, translateText)}</TitleElement>
|
||||
</TextWithHelp>}
|
||||
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
|
||||
</ToolbarGroup> : null}
|
||||
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
|
||||
{variant === "editor" ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ export type PageArchetype = "overview" | "collection" | "detail" | "editor" | "w
|
||||
|
||||
export type PageHeaderProps = {
|
||||
title: ReactNode;
|
||||
titleHelp?: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
loading?: boolean;
|
||||
@@ -20,6 +21,7 @@ export type PageHeaderProps = {
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
titleHelp,
|
||||
description,
|
||||
actions,
|
||||
loading = false,
|
||||
@@ -38,7 +40,7 @@ export function PageHeader({
|
||||
return (
|
||||
<header className={classes}>
|
||||
<div className="page-layout-heading-copy">
|
||||
<PageTitle loading={loading}>{title}</PageTitle>
|
||||
<PageTitle loading={loading} titleHelp={titleHelp}>{title}</PageTitle>
|
||||
{description && (typeof description === "string"
|
||||
? <p className="page-layout-description">{translateText(description)}</p>
|
||||
: <div className="page-layout-description">{translateReactNode(description, translateText)}</div>)}
|
||||
@@ -52,6 +54,7 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & {
|
||||
/** Semantic page intent. This is independent from viewport/layout geometry. */
|
||||
archetype: PageArchetype;
|
||||
title: ReactNode;
|
||||
titleHelp?: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
@@ -75,6 +78,7 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & {
|
||||
export default function PageLayout({
|
||||
archetype,
|
||||
title,
|
||||
titleHelp,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
@@ -123,6 +127,7 @@ export default function PageLayout({
|
||||
{showHeader && (
|
||||
<PageHeader
|
||||
title={title}
|
||||
titleHelp={titleHelp}
|
||||
description={description}
|
||||
actions={actions}
|
||||
loading={headerLoading ?? loading}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import TextWithHelp from "./help/TextWithHelp";
|
||||
|
||||
type PageTitleProps = {
|
||||
children: React.ReactNode;
|
||||
titleHelp?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export default function PageTitle({ children, loading = false }: PageTitleProps) {
|
||||
export default function PageTitle({ children, titleHelp, loading = false }: PageTitleProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedChildren = typeof children === "string" ? translateText(children) : children;
|
||||
return (
|
||||
<h1 className="page-title-with-loader">
|
||||
<span>{renderedChildren}</span>
|
||||
<div className="page-title-with-loader">
|
||||
<TextWithHelp as="div" data-help-anchor="title" help={titleHelp}>
|
||||
<h1>{renderedChildren}</h1>
|
||||
</TextWithHelp>
|
||||
{loading && <LoadingIndicator label="i18n:govoplan-core.loading_page_data.85fe9edf" />}
|
||||
</h1>);
|
||||
</div>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import PageLayout, { type PageArchetype } from "../PageLayout";
|
||||
export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & {
|
||||
archetype?: PageArchetype;
|
||||
title: string;
|
||||
titleHelp?: ReactNode;
|
||||
description: string;
|
||||
loading?: boolean;
|
||||
loadingLabel?: string;
|
||||
@@ -18,6 +19,7 @@ export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & {
|
||||
export default function AdminPageLayout({
|
||||
archetype = "detail",
|
||||
title,
|
||||
titleHelp,
|
||||
description,
|
||||
loading = false,
|
||||
loadingLabel = "i18n:govoplan-core.loading_administration_data.643bd894",
|
||||
@@ -35,6 +37,7 @@ export default function AdminPageLayout({
|
||||
<PageLayout
|
||||
archetype={archetype}
|
||||
title={title}
|
||||
titleHelp={titleHelp}
|
||||
description={description}
|
||||
loading={loading}
|
||||
loadingLabel={loadingLabel}
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type TextWithHelpProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
|
||||
children: ReactNode;
|
||||
help?: ReactNode;
|
||||
/** Use a div when the text is a heading or another block element. */
|
||||
as?: "span" | "div";
|
||||
};
|
||||
|
||||
/** Keeps contextual help beside its visible text, outside its accessible name. */
|
||||
export default function TextWithHelp({ children, help, as = "span", className = "", ...props }: TextWithHelpProps) {
|
||||
const Element = as;
|
||||
return (
|
||||
<Element data-help-anchor="text" {...props} className={["text-with-help", className].filter(Boolean).join(" ")}>
|
||||
{typeof children === "string" || typeof children === "number" ? <span>{children}</span> : children}
|
||||
{help && <span className="text-with-help-help">{help}</span>}
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRi
|
||||
import StatusBadge from "../StatusBadge";
|
||||
import ListSelectionFilter, { type ListFilterOption } from "../ListSelectionFilter";
|
||||
import TableActionGroup from "./TableActionGroup";
|
||||
import { dataGridRowIndices } from "./dataGridRowIndices";
|
||||
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
|
||||
import {
|
||||
DATA_GRID_MAX_TRACK_WIDTH,
|
||||
@@ -89,6 +90,9 @@ export type DataGridColumn<T> = {
|
||||
* maxima only when that is required to consume the complete container.
|
||||
*/
|
||||
maxWidth?: number;
|
||||
/** Automatic-fit preference only, not a manual resize limit. Cover fitting
|
||||
* may exceed this to fill the container after other preferred widths. */
|
||||
preferredMaxWidth?: number;
|
||||
resizable?: boolean;
|
||||
/** @deprecated Kept for source compatibility. Resize space is now distributed across resizable columns. */
|
||||
fill?: boolean;
|
||||
@@ -545,6 +549,8 @@ export default function DataGrid<T>({
|
||||
return result;
|
||||
}, [columns, rows]);
|
||||
|
||||
const originalRowIndex = useMemo(() => dataGridRowIndices(rows), [rows]);
|
||||
|
||||
const visibleRows = useMemo(() => {
|
||||
if (serverQueryMode) return rows;
|
||||
const filters = state.filters ?? {};
|
||||
@@ -558,14 +564,14 @@ export default function DataGrid<T>({
|
||||
const sortColumn = columns.find((column) => column.id === state.sort?.columnId);
|
||||
if (!sortColumn) return filtered;
|
||||
return [...filtered].sort((a, b) => {
|
||||
const aIndex = rows.indexOf(a);
|
||||
const bIndex = rows.indexOf(b);
|
||||
const aIndex = originalRowIndex(a);
|
||||
const bIndex = originalRowIndex(b);
|
||||
const aValue = sortColumn.sortValue?.(a, aIndex) ?? sortColumn.value?.(a, aIndex) ?? "";
|
||||
const bValue = sortColumn.sortValue?.(b, bIndex) ?? sortColumn.value?.(b, bIndex) ?? "";
|
||||
const result = compareValues(aValue, bValue);
|
||||
return state.sort?.direction === "desc" ? -result : result;
|
||||
});
|
||||
}, [rows, columns, state.filters, state.sort, filterTypes, serverQueryMode]);
|
||||
}, [rows, columns, state.filters, state.sort, filterTypes, serverQueryMode, originalRowIndex]);
|
||||
|
||||
const paginationMode = pagination?.mode ?? "client";
|
||||
const paginationTotal = pagination ?
|
||||
@@ -851,7 +857,7 @@ export default function DataGrid<T>({
|
||||
</div>);
|
||||
|
||||
})() : renderedRows.map((row, visibleIndex) => {
|
||||
const originalIndex = rows.indexOf(row);
|
||||
const originalIndex = originalRowIndex(row);
|
||||
const rowKey = getRowKey(row, originalIndex);
|
||||
const rowClass = rowClassName?.(row, originalIndex);
|
||||
const parityClass = visibleIndex % 2 === 0 ? "data-grid-row-even" : "data-grid-row-odd";
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/** Cache indexOf's first-occurrence semantics without scanning rows per comparison/cell. */
|
||||
export function dataGridRowIndices<T>(rows: readonly T[]): (row: T) => number {
|
||||
const indices = new Map<T, number>();
|
||||
rows.forEach((row, index) => {
|
||||
if (!indices.has(row)) indices.set(row, index);
|
||||
});
|
||||
// Map uses SameValueZero, whereas indexOf never finds NaN.
|
||||
return (row) => row !== row ? -1 : indices.get(row) ?? -1;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export type DataGridSizingColumn = {
|
||||
width?: number | string;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
preferredMaxWidth?: number;
|
||||
resizable?: boolean;
|
||||
/** @deprecated Retained for compatibility with older column definitions. */
|
||||
fill?: boolean;
|
||||
@@ -54,7 +55,8 @@ export function dataGridLayoutSignature(
|
||||
column.sortable ? "sort" : "",
|
||||
column.filterable ? "filter" : "",
|
||||
column.columnType ?? "default",
|
||||
column.sticky ?? ""
|
||||
column.sticky ?? "",
|
||||
...(column.preferredMaxWidth === undefined ? [] : [`preferred-max:${column.preferredMaxWidth}`])
|
||||
].join(":")).join("|");
|
||||
return `v3::${columnSignature}::${initialFit}::${resizeBehavior}`;
|
||||
}
|
||||
@@ -113,6 +115,18 @@ export function isFlexibleDataGridWidth(width?: string | number, fill = false):
|
||||
|| minmaxParts(normalized) !== null;
|
||||
}
|
||||
|
||||
/** Presentation-only automatic-fit ceiling; explicit user widths still use
|
||||
* maxWidth. Cover may exceed preferred ceilings to avoid a blank filler. */
|
||||
export function effectiveDataGridColumnPreferredMaxWidth(
|
||||
column: DataGridSizingColumn,
|
||||
minimum = effectiveDataGridColumnMinWidth(column)
|
||||
): number {
|
||||
const maximum = effectiveDataGridColumnMaxWidth(column, minimum);
|
||||
return column.preferredMaxWidth !== undefined && Number.isFinite(column.preferredMaxWidth)
|
||||
? Math.max(minimum, Math.min(maximum, column.preferredMaxWidth))
|
||||
: maximum;
|
||||
}
|
||||
|
||||
export function dataGridColumnResizeWeight(column: DataGridSizingColumn): number {
|
||||
if (column.fill) return 1;
|
||||
const width = column.width;
|
||||
@@ -127,7 +141,7 @@ export function preferredDataGridColumnWidth(
|
||||
measuredWidth?: number
|
||||
): number {
|
||||
const minimum = effectiveDataGridColumnMinWidth(column);
|
||||
const maximum = effectiveDataGridColumnMaxWidth(column, minimum);
|
||||
const maximum = effectiveDataGridColumnPreferredMaxWidth(column, minimum);
|
||||
const width = column.width;
|
||||
let preferred: number | null = null;
|
||||
|
||||
@@ -213,7 +227,7 @@ export function fitDataGridColumns(
|
||||
.filter((column) => remaining >= 0
|
||||
? dataGridColumnGrowthWeight(column) > 0
|
||||
: isFlexibleDataGridWidth(column.width, column.fill) || Boolean(column.resizable))
|
||||
.map((column) => resizeTargetForLayout(
|
||||
.map((column) => fitTargetForLayout(
|
||||
column,
|
||||
widths,
|
||||
remaining >= 0 ? dataGridColumnGrowthWeight(column) : dataGridColumnResizeWeight(column)
|
||||
@@ -224,14 +238,14 @@ export function fitDataGridColumns(
|
||||
widths,
|
||||
remaining,
|
||||
responsiveColumns.map((column) =>
|
||||
resizeTargetForLayout(column, widths, dataGridColumnGrowthWeight(column))
|
||||
fitTargetForLayout(column, widths, dataGridColumnGrowthWeight(column))
|
||||
)
|
||||
);
|
||||
remaining = applyDataGridDistribution(
|
||||
widths,
|
||||
remaining,
|
||||
automaticColumns.map((column) =>
|
||||
resizeTargetForLayout(column, widths, widths[column.id])
|
||||
fitTargetForLayout(column, widths, widths[column.id])
|
||||
)
|
||||
);
|
||||
// Cover is a stronger invariant than maxWidth. Once preferred maxima have
|
||||
@@ -240,7 +254,7 @@ export function fitDataGridColumns(
|
||||
widths,
|
||||
remaining,
|
||||
coverageColumns.map((column) =>
|
||||
resizeTargetForLayout(column, widths, widths[column.id], DATA_GRID_MAX_TRACK_WIDTH)
|
||||
fitTargetForLayout(column, widths, widths[column.id], DATA_GRID_MAX_TRACK_WIDTH)
|
||||
)
|
||||
);
|
||||
} else if (remaining < -0.01) {
|
||||
@@ -249,13 +263,13 @@ export function fitDataGridColumns(
|
||||
remaining,
|
||||
responsiveColumns
|
||||
.filter((column) => column.resizable || isFlexibleDataGridWidth(column.width, column.fill))
|
||||
.map((column) => resizeTargetForLayout(column, widths, widths[column.id]))
|
||||
.map((column) => fitTargetForLayout(column, widths, widths[column.id]))
|
||||
);
|
||||
remaining = applyDataGridDistribution(
|
||||
widths,
|
||||
remaining,
|
||||
automaticColumns.map((column) =>
|
||||
resizeTargetForLayout(column, widths, widths[column.id])
|
||||
fitTargetForLayout(column, widths, widths[column.id])
|
||||
)
|
||||
);
|
||||
if (responsiveUserLayout && remaining < -0.01) {
|
||||
@@ -264,7 +278,7 @@ export function fitDataGridColumns(
|
||||
remaining,
|
||||
coverageColumns
|
||||
.filter((column) => userWidths[column.id] !== undefined)
|
||||
.map((column) => resizeTargetForLayout(column, widths, widths[column.id]))
|
||||
.map((column) => fitTargetForLayout(column, widths, widths[column.id]))
|
||||
);
|
||||
}
|
||||
// At the container where it was chosen, an explicit user layout remains
|
||||
@@ -499,6 +513,15 @@ function resizeTargetForLayout(
|
||||
};
|
||||
}
|
||||
|
||||
function fitTargetForLayout(
|
||||
column: DataGridSizingColumn,
|
||||
widths: Record<string, number>,
|
||||
weight: number,
|
||||
maximum = effectiveDataGridColumnPreferredMaxWidth(column)
|
||||
): DataGridResizeTarget {
|
||||
return resizeTargetForLayout(column, widths, weight, maximum);
|
||||
}
|
||||
|
||||
function applyDataGridDistribution(
|
||||
widths: Record<string, number>,
|
||||
requestedAmount: number,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ApiSettings, AuthActionUiCapability, AuthInfo, AuthUpdate } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import ModuleLoadBoundary from "../../components/ModuleLoadBoundary";
|
||||
import HelpMenu from "../../layout/HelpMenu";
|
||||
|
||||
export default function AuthActionGate({
|
||||
settings, auth, capability, onAuthChange, onSignOut
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
capability: AuthActionUiCapability | null;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
onSignOut: () => void;
|
||||
}) {
|
||||
const action = auth.user.required_auth_action;
|
||||
const RequiredAction = action && capability?.actions.includes(action)
|
||||
? capability.RequiredAction : null;
|
||||
return <main className="public-landing auth-action-page">
|
||||
<section className="public-card">
|
||||
<ModuleLoadBoundary resetKey={action ?? "auth-action"}>
|
||||
{RequiredAction
|
||||
? <RequiredAction settings={settings} auth={auth} onAuthChange={onAuthChange} />
|
||||
: <DismissibleAlert tone="warning" dismissible={false}>
|
||||
i18n:govoplan-core.required_account_action_unavailable
|
||||
</DismissibleAlert>}
|
||||
</ModuleLoadBoundary>
|
||||
<div className="public-actions"><Button onClick={onSignOut}>i18n:govoplan-core.sign_out.dc1649a1</Button><HelpMenu auth={auth} /></div>
|
||||
</section>
|
||||
</main>;
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { FormLayout } from "../../components/ContentGrid";
|
||||
import { useId, useState } from "react";
|
||||
import type { ApiSettings, LoginResponse } from "../../types";
|
||||
import type { ApiSettings, AuthActionUiCapability, LoginResponse } from "../../types";
|
||||
import { login } from "../../api/auth";
|
||||
import Button from "../../components/Button";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export default function LoginModal({
|
||||
settings,
|
||||
@@ -26,6 +28,8 @@ export default function LoginModal({
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const formId = useId();
|
||||
const authActions = usePlatformUiCapability<AuthActionUiCapability>("auth.actions");
|
||||
const LoginHelp = authActions?.LoginHelp;
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -38,6 +42,7 @@ export default function LoginModal({
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPassword("");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +69,7 @@ export default function LoginModal({
|
||||
<PasswordField helpContextId="access.authentication.password" helpModuleId="access" value={password} autoComplete="current-password" onValueChange={setPassword} />
|
||||
</FormField>
|
||||
</FormLayout>
|
||||
{LoginHelp && <Suspense fallback={null}><LoginHelp settings={settings} onNavigate={onClose} /></Suspense>}
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
|
||||
@@ -148,10 +148,10 @@ export function RetentionPolicyScopeManager({
|
||||
<div className="retention-policy-manager">
|
||||
{targetSelectionRequired &&
|
||||
<Card
|
||||
title={i18nMessage("i18n:govoplan-core.value_scope", { value0: targetLabel })}
|
||||
title={i18nMessage("i18n:govoplan-core.value_scope", { value0: targetLabel })} titleHelp={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
|
||||
helpContextId="policy.retention.target"
|
||||
helpModuleId="policy"
|
||||
actions={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
|
||||
|
||||
>
|
||||
<div className="retention-policy-target-row">
|
||||
<FormField label={targetLabel} helpContextId="policy.retention.target" helpModuleId="policy">
|
||||
@@ -344,17 +344,17 @@ export function RetentionPolicyEditor({
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={title}
|
||||
title={title} titleHelp={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
|
||||
helpContextId="policy.retention"
|
||||
helpModuleId="policy"
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
|
||||
|
||||
<Button helpContextId="policy.retention.action.reload" helpModuleId="policy" onClick={() => void loadPolicy()} disabled={Boolean(reloadDisabledReason)} disabledReason={reloadDisabledReason}>{loading ? "i18n:govoplan-core.loading.33ce4174" : "i18n:govoplan-core.reload.cce71553"}</Button>
|
||||
<Button helpContextId="policy.retention.action.save" helpModuleId="policy" variant="primary" onClick={() => void savePolicy()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_policy.77d67ce3"}</Button>
|
||||
</div>
|
||||
}>
|
||||
|
||||
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-core.loading_retention_policy.dcd30fb6">
|
||||
<div className="retention-policy-editor">
|
||||
<p className="muted small-note retention-policy-description">{description ?? defaultDescription}</p>
|
||||
|
||||
@@ -360,13 +360,13 @@ export default function SettingsPage({
|
||||
>
|
||||
<PageLayout
|
||||
archetype={editorSection ? "editor" : "workspace"}
|
||||
title="i18n:govoplan-core.settings.c7f73bb5"
|
||||
title="i18n:govoplan-core.settings.c7f73bb5" titleHelp={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
|
||||
description="i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56"
|
||||
actions={editorSection ? (
|
||||
<PageActionBar
|
||||
variant="editor"
|
||||
state={editorSaving ? "saving" : editorDirty ? "dirty" : "clean"}
|
||||
helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
|
||||
|
||||
discardAction={{ label: "i18n:govoplan-core.discard.36fff63c", onClick: discardEditor }}
|
||||
saveAction={{
|
||||
label: active === "profile" ? "i18n:govoplan-core.save_profile.f597c0e8" : "i18n:govoplan-core.save_preferences.0f1a7e44",
|
||||
@@ -374,7 +374,7 @@ export default function SettingsPage({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PageActionBar variant="workspace" helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />} />
|
||||
<PageActionBar variant="workspace" />
|
||||
)}
|
||||
mode="workspace"
|
||||
>
|
||||
@@ -454,19 +454,19 @@ export default function SettingsPage({
|
||||
help="i18n:govoplan-core.prepared_ui_preference_for_denser_tables_the_cur.45698d83"
|
||||
checked={compactTables}
|
||||
onChange={setCompactTables} />
|
||||
|
||||
|
||||
<ToggleSwitch
|
||||
label="i18n:govoplan-core.show_inline_help_hints.47cf5aaa"
|
||||
help="i18n:govoplan-core.controls_contextual_ui_help_markers_once_persist.0eaef9c4"
|
||||
checked={showHelpHints}
|
||||
onChange={setShowHelpHints} />
|
||||
|
||||
|
||||
<ToggleSwitch
|
||||
label="i18n:govoplan-core.reduce_motion.25a5aef5"
|
||||
help="i18n:govoplan-core.prepared_preference_for_users_who_prefer_fewer_a.b288e8ab"
|
||||
checked={reduceMotion}
|
||||
onChange={setReduceMotion} />
|
||||
|
||||
|
||||
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
|
||||
</FormGrid>
|
||||
</Card>
|
||||
@@ -534,14 +534,14 @@ export default function SettingsPage({
|
||||
help="i18n:govoplan-core.keeps_campaign_and_admin_section_navigation_in_v.f3602938"
|
||||
checked={stickySections}
|
||||
onChange={setStickySections} />
|
||||
|
||||
|
||||
<ToggleSwitch
|
||||
label="i18n:govoplan-core.keep_page_shell_visible_while_loading.17fc142a"
|
||||
help="i18n:govoplan-core.the_current_ui_already_keeps_the_section_shell_v.c9bbc227"
|
||||
checked
|
||||
disabled
|
||||
onChange={() => undefined} />
|
||||
|
||||
|
||||
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
|
||||
</FormGrid>
|
||||
</Card>
|
||||
@@ -580,7 +580,7 @@ export default function SettingsPage({
|
||||
value={settings.apiKey}
|
||||
autoComplete="off"
|
||||
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })} />
|
||||
|
||||
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { PlatformTranslations } from "../types";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-core.operator_queue.72492fb5": "Operator queue",
|
||||
"i18n:govoplan-core.required_account_action_unavailable": "A required account action must be completed before you can continue. The account module is loading or unavailable. If this persists, contact your administrator or sign out.",
|
||||
"i18n:govoplan-core.optional_module_load_failed": "An enabled module could not load after retrying: {value0}. Its screens and integrations may be unavailable; the module has not been uninstalled. Save any other drafts before reloading this page.",
|
||||
"i18n:govoplan-core.data_grid_resize_help": "Drag to resize. Left/Right: 10 px; Shift: 40 px. Enter or double-click: reset this column. Escape: cancel dragging.",
|
||||
"i18n:govoplan-core.inherit_governed_palette": "Inherit governed default",
|
||||
@@ -741,6 +743,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-core.operator_queue.72492fb5": "Operator-Warteschlange",
|
||||
"i18n:govoplan-core.required_account_action_unavailable": "Bevor Sie fortfahren können, müssen Sie eine erforderliche Kontoaktion abschließen. Das Kontomodul wird geladen oder ist nicht verfügbar. Wenden Sie sich bei anhaltenden Problemen an die Administration oder melden Sie sich ab.",
|
||||
"i18n:govoplan-core.optional_module_load_failed": "Ein aktiviertes Modul konnte auch nach einem Wiederholungsversuch nicht geladen werden: {value0}. Seine Ansichten und Integrationen sind möglicherweise nicht verfügbar; das Modul wurde nicht deinstalliert. Andere Entwürfe vor dem Neuladen dieser Seite speichern.",
|
||||
"i18n:govoplan-core.data_grid_resize_help": "Zum Ändern der Breite ziehen. Links/Rechts: 10 px; Umschalt: 40 px. Eingabe oder Doppelklick: Spalte zurücksetzen. Escape: Ziehen abbrechen.",
|
||||
"i18n:govoplan-core.inherit_governed_palette": "Verwalteten Standard übernehmen",
|
||||
@@ -1291,7 +1295,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.show_password.044b852f": "Show password",
|
||||
"i18n:govoplan-core.sign_in_to_open_the_modules_available_to_your_te.8bb7dab4": "Sign in to open the modules available to your tenant and role.",
|
||||
"i18n:govoplan-core.sign_in.ada2e9e9": "Anmelden",
|
||||
"i18n:govoplan-core.sign_out.dc1649a1": "Sign out",
|
||||
"i18n:govoplan-core.sign_out.dc1649a1": "Abmelden",
|
||||
"i18n:govoplan-core.signing_in.c66b2adc": "Signing in…",
|
||||
"i18n:govoplan-core.smtp_host.2d4a434b": "SMTP host",
|
||||
"i18n:govoplan-core.smtp_port.65b5a108": "SMTP port",
|
||||
|
||||
@@ -3,6 +3,9 @@ export const navigationEditorTranslations: Record<string, Record<string, string>
|
||||
"en": {
|
||||
"help": "Drag the handles to reorder modules and separators. Keyboard: Space to pick up, arrows to move, Enter to drop, Escape to cancel. Removing a module only hides it here; locked entries stay visible.",
|
||||
"inherit": "Use inherited layout",
|
||||
"inherited_status": "No override at this level. The standard layout groups available modules by product area; system and tenant defaults may customize it. Group headings become horizontal lines when collapsed.",
|
||||
"grouped_status": "Custom layout with group separators. Use inherited layout to remove this override and restore the current defaults; Save applies the change.",
|
||||
"flat_status": "Custom layout without group headings or dividers. Add a separator to create a group, or use inherited layout to restore the current defaults; Save applies the change.",
|
||||
"available": "Available modules",
|
||||
"all_added": "All available modules are included",
|
||||
"add_module": "Add module",
|
||||
@@ -25,6 +28,9 @@ export const navigationEditorTranslations: Record<string, Record<string, string>
|
||||
"de": {
|
||||
"help": "Ziehen Sie die Griffe, um Module und Trennlinien anzuordnen. Tastatur: Leertaste zum Aufnehmen, Pfeile zum Verschieben, Eingabe zum Ablegen, Escape zum Abbrechen. Entfernen blendet Module nur hier aus; gesperrte Einträge bleiben sichtbar.",
|
||||
"inherit": "Geerbte Anordnung verwenden",
|
||||
"inherited_status": "Keine eigene Anordnung auf dieser Ebene. Die Standardanordnung gruppiert verfügbare Module nach Produktbereich; System- und Mandantenvorgaben können sie anpassen. Eingeklappt werden Gruppenüberschriften zu horizontalen Linien.",
|
||||
"grouped_status": "Eigene Anordnung mit Gruppentrennern. Mit „Geerbte Anordnung verwenden“ entfernen Sie diese Anpassung und stellen die aktuellen Vorgaben wieder her; Speichern übernimmt die Änderung.",
|
||||
"flat_status": "Eigene Anordnung ohne Gruppenüberschriften oder Trennlinien. Fügen Sie eine Trennlinie für eine Gruppe hinzu oder stellen Sie mit „Geerbte Anordnung verwenden“ die aktuellen Vorgaben wieder her; Speichern übernimmt die Änderung.",
|
||||
"available": "Verfügbare Module",
|
||||
"all_added": "Alle verfügbaren Module sind enthalten",
|
||||
"add_module": "Modul hinzufügen",
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./types";
|
||||
export * from "./api/client";
|
||||
export * from "./api/concurrency";
|
||||
export * from "./api/auth";
|
||||
export * from "./api/authAuthority";
|
||||
export * from "./api/platform";
|
||||
export * from "./api/notificationSummary";
|
||||
export * from "./api/adminCommon";
|
||||
@@ -239,6 +240,8 @@ export { default as EmailAddressInput } from "./components/email/EmailAddressInp
|
||||
export { default as MailServerSettingsPanel, MailImapFolderMappingsEditor, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapFolderMappingKeys, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailImapFolderMappings, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
|
||||
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
|
||||
export { default as FieldLabel } from "./components/help/FieldLabel";
|
||||
export { default as TextWithHelp } from "./components/help/TextWithHelp";
|
||||
export type { TextWithHelpProps } from "./components/help/TextWithHelp";
|
||||
export { default as DocumentationHelpLink, DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
|
||||
export { documentationHelpHref } from "./components/help/documentationHelp";
|
||||
export type { DocumentationHelpReference } from "./components/help/documentationHelp";
|
||||
|
||||
@@ -100,12 +100,19 @@ const topLevelRouteLabels: Record<string, string> = {
|
||||
files: "i18n:govoplan-core.files.6ce6c512",
|
||||
"address-book": "i18n:govoplan-core.address_book.f6327f59",
|
||||
reports: "i18n:govoplan-core.reports.88bc3fe3",
|
||||
operator: "i18n:govoplan-core.operator_queue.72492fb5",
|
||||
settings: "i18n:govoplan-core.settings.c7f73bb5",
|
||||
admin: "i18n:govoplan-core.admin.4e7afebc"
|
||||
};
|
||||
|
||||
function labelFor(value: string, parts: string[], index: number): string {
|
||||
if (parts[0] === "campaigns" && index === 1) return "i18n:govoplan-core.campaign.69390e16";
|
||||
if (parts[0] === "campaigns" && index === 1) {
|
||||
// These exact collection routes are module views, not campaign identifiers.
|
||||
// A deeper campaign editor still uses its singular Campaign > Report label.
|
||||
if (parts.length === 2 && value === "reports") return "i18n:govoplan-core.reports.88bc3fe3";
|
||||
if (parts.length === 2 && value === "queue") return "i18n:govoplan-core.operator_queue.72492fb5";
|
||||
return "i18n:govoplan-core.campaign.69390e16";
|
||||
}
|
||||
if (parts[0] === "campaigns" && index >= 2) {
|
||||
const mapped = campaignRouteLabels[value];
|
||||
if (mapped) return mapped;
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
/** One request at a time, with a mandatory trailing read after an invalidation.
|
||||
* Superseded responses/errors never publish. This does not cache authorization.
|
||||
*/
|
||||
export function createModuleRefresh<T>({
|
||||
load, accept, reject, now = Date.now
|
||||
}: {
|
||||
load: () => Promise<T>;
|
||||
accept: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
now?: () => number;
|
||||
}) {
|
||||
let disposed = false;
|
||||
let inFlight = false;
|
||||
let generation = 0;
|
||||
let lastRefreshAt = -Infinity;
|
||||
|
||||
async function run() {
|
||||
if (disposed || inFlight) return;
|
||||
inFlight = true;
|
||||
const requestedGeneration = generation;
|
||||
lastRefreshAt = now();
|
||||
try {
|
||||
const value = await load();
|
||||
if (!disposed && requestedGeneration === generation) accept(value);
|
||||
} catch (error) {
|
||||
if (!disposed && requestedGeneration === generation) reject(error);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (!disposed && requestedGeneration !== generation) void run();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
invalidate() {
|
||||
if (disposed) return;
|
||||
generation += 1;
|
||||
void run();
|
||||
},
|
||||
refreshVisible(visible: boolean) {
|
||||
if (visible && !inFlight && now() - lastRefreshAt >= 5_000) void run();
|
||||
},
|
||||
dispose() { disposed = true; }
|
||||
};
|
||||
}
|
||||
@@ -81,6 +81,16 @@ export function groupNavigationItems(
|
||||
left.order - right.order ||
|
||||
left.label.localeCompare(right.label)
|
||||
);
|
||||
// Keep an entry's declared area when known; use composed-owner aliases only
|
||||
// when the placement owner does not have a direct area contribution.
|
||||
const areaByItem = new Map(items.map((item) => {
|
||||
const surfaceId = item.surfaceId;
|
||||
const directArea = surfaceId
|
||||
? areas.find((area) => area.surfaceIds.has(surfaceId))
|
||||
: undefined;
|
||||
const aliases = navigationItemAliases(item);
|
||||
return [item, directArea ?? areas.find((area) => aliases.some((id) => area.surfaceIds.has(id)))] as const;
|
||||
}));
|
||||
const assigned = new Set<string>();
|
||||
const groups: NavigationGroup[] = [];
|
||||
const pinned = items.filter((item) => item.to === "/dashboard");
|
||||
@@ -91,7 +101,7 @@ export function groupNavigationItems(
|
||||
const areaItems = items.filter(
|
||||
(item) =>
|
||||
!assigned.has(item.to) &&
|
||||
Boolean(item.surfaceId && area.surfaceIds.has(item.surfaceId))
|
||||
areaByItem.get(item)?.id === area.id
|
||||
);
|
||||
if (!areaItems.length) continue;
|
||||
areaItems.forEach((item) => assigned.add(item.to));
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
padding: 42px 48px;
|
||||
}
|
||||
|
||||
.auth-action-page form { margin-top: 18px; }
|
||||
@media (max-width: 600px) {
|
||||
.auth-action-page { padding: 16px; }
|
||||
.auth-action-page .public-card { padding: 24px; }
|
||||
}
|
||||
|
||||
.public-kicker {
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -3,6 +3,44 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.page-title-with-loader > .loading-indicator {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.text-with-help {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.text-with-help > :first-child {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.text-with-help-help {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: normal;
|
||||
line-height: 1;
|
||||
}
|
||||
.text-with-help-help:empty {
|
||||
display: none;
|
||||
}
|
||||
.card-title-with-help,
|
||||
.dialog-title-with-help {
|
||||
min-width: 0;
|
||||
}
|
||||
.page-action-bar-title > :is(h1, h2, h3) {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.loading-indicator {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -263,24 +263,7 @@
|
||||
.card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
|
||||
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
|
||||
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
|
||||
.card-body { padding: 22px 24px; }
|
||||
|
||||
/* Table surfaces have an explicit inset contract, independent of child count. */
|
||||
.card-body.card-body-table { min-width: 0; padding: 0; }
|
||||
.card-body-table > :is(.data-grid-shell, .admin-table-surface, .connection-tree),
|
||||
.card-body-table > .loading-frame > :is(.data-grid-shell, .admin-table-surface, .connection-tree) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.card-body-table .admin-table-surface > .data-grid-shell {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.card-body { min-width: 0; padding: 22px 24px; }
|
||||
.metric-group-layout {
|
||||
--metric-group-column-minimum: 140px;
|
||||
min-width: 0;
|
||||
|
||||
+26
-20
@@ -168,37 +168,43 @@
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.card-body:not(.card-body-table) > .admin-table-surface:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
max-width: inherit;
|
||||
/* The card owns its insets; a table never grows beyond its measured container.
|
||||
Expanding a child with calc(100% + 48px) while retaining max-width: 100%
|
||||
clips the expansion and leaves a gap. Remove the parent's padding instead.
|
||||
Explicit table bodies also allow padded notices/pagination alongside a grid.
|
||||
Legacy table-only bodies retain edge-to-edge layout through LoadingFrame;
|
||||
its out-of-flow overlay must not change geometry when loading starts. */
|
||||
.card-body.card-body-table,
|
||||
.card-body:has(> :is(.data-grid-shell, .admin-table-surface, .connection-tree):only-child),
|
||||
.card-body:has(> .loading-frame:only-child > :is(.data-grid-shell, .admin-table-surface, .connection-tree)):not(:has(> .loading-frame > :not(.data-grid-shell, .admin-table-surface, .connection-tree, .loading-frame-overlay))) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-body > .admin-table-surface:only-child > .data-grid-shell {
|
||||
.card-body-table > :is(.data-grid-shell, .admin-table-surface, .connection-tree),
|
||||
.card-body-table > .loading-frame > :is(.data-grid-shell, .admin-table-surface, .connection-tree),
|
||||
.card-body > :is(.data-grid-shell, .admin-table-surface, .connection-tree):only-child,
|
||||
.card-body > .loading-frame:only-child:not(:has(> :not(.data-grid-shell, .admin-table-surface, .connection-tree, .loading-frame-overlay))) > :is(.data-grid-shell, .admin-table-surface, .connection-tree) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.card-body:not(.card-body-table) > .data-grid-shell:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
.card-body-table .admin-table-surface > .data-grid-shell,
|
||||
.card-body > .admin-table-surface:only-child > .data-grid-shell,
|
||||
.card-body > .loading-frame:only-child:not(:has(> :not(.admin-table-surface, .loading-frame-overlay))) > .admin-table-surface > .data-grid-shell {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.card-body:not(.card-body-table) > .connection-tree:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card-body:not(.card-body-table) > .loading-frame:only-child > .connection-tree:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
.card-body-table > .loading-frame > .loading-frame-overlay,
|
||||
.card-body > .loading-frame:only-child:has(> :is(.data-grid-shell, .admin-table-surface, .connection-tree)):not(:has(> :not(.data-grid-shell, .admin-table-surface, .connection-tree, .loading-frame-overlay))) > .loading-frame-overlay {
|
||||
/* A table body has no spare inset for LoadingFrame's content-mode bleed. */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.data-grid-container {
|
||||
|
||||
+13
-1
@@ -1,4 +1,5 @@
|
||||
import type { ComponentType, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
import type { DocumentationHelpReference } from "./components/help/documentationHelp";
|
||||
|
||||
export type ApiSettings = {
|
||||
apiBaseUrl: string;
|
||||
@@ -35,6 +36,8 @@ export type AuthUser = {
|
||||
tenant_display_name?: string | null;
|
||||
is_tenant_admin?: boolean;
|
||||
password_reset_required?: boolean;
|
||||
required_auth_action?: "change_password" | null;
|
||||
local_password?: boolean;
|
||||
preferred_language?: string | null;
|
||||
enabled_language_codes?: string[];
|
||||
ui_preferences?: UserUiPreferences;
|
||||
@@ -134,6 +137,13 @@ export type ActingContextRuntimeUiCapability = {
|
||||
Selector: ComponentType<ActingContextSelectorProps>;
|
||||
};
|
||||
|
||||
/** Optional authentication UI, including actions before normal module access. */
|
||||
export type AuthActionUiCapability = {
|
||||
actions: readonly string[];
|
||||
RequiredAction: ComponentType<ActingContextSelectorProps>;
|
||||
LoginHelp?: ComponentType<{ settings: ApiSettings; onNavigate: () => void }>;
|
||||
};
|
||||
|
||||
export type AuthInfo = {
|
||||
user: AuthUser;
|
||||
// Backwards-compatible active tenant alias returned by older/newer APIs.
|
||||
@@ -162,7 +172,7 @@ export type AuthUpdate = Partial<Omit<AuthInfo, "user" | "tenant" | "active_tena
|
||||
export type AuthSessionInfo = {
|
||||
authenticated: boolean;
|
||||
auth_method: "session" | "api_key";
|
||||
user: Pick<AuthUser, "id" | "account_id" | "email" | "display_name" | "tenant_display_name" | "is_tenant_admin" | "password_reset_required">;
|
||||
user: Pick<AuthUser, "id" | "account_id" | "email" | "display_name" | "tenant_display_name" | "is_tenant_admin" | "password_reset_required" | "required_auth_action" | "local_password">;
|
||||
tenant: AuthTenant;
|
||||
active_tenant: AuthTenant;
|
||||
session_id?: string | null;
|
||||
@@ -796,6 +806,8 @@ export type DashboardWidgetRenderContext = PlatformRouteContext & {
|
||||
export type DashboardWidgetContribution = {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Module-owned contextual documentation displayed beside the widget title. */
|
||||
documentation?: DocumentationHelpReference;
|
||||
description?: string;
|
||||
moduleId?: string;
|
||||
category?: string;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { transformSync } = require("esbuild");
|
||||
const source = readFileSync(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const code = transformSync(source, { loader: "tsx", format: "cjs", target: "es2022" }).code;
|
||||
const context = vm.createContext({
|
||||
module: { exports: {} },
|
||||
require: (name) => name === "react" ? { lazy: () => null } : {}
|
||||
});
|
||||
context.exports = context.module.exports;
|
||||
vm.runInContext(code, context);
|
||||
const { normalizeAuthInfo, sessionMatchesAuth } = context.module.exports;
|
||||
const tenant = { id: "tenant-1", name: "Tenant", slug: "tenant" };
|
||||
const base = {
|
||||
user: { id: "membership-1", account_id: "account-1", email: "person@example.test", password_reset_required: true },
|
||||
tenant,
|
||||
principal: { auth_method: "session", account_id: "account-1", membership_id: "membership-1", session_id: "session-1", scopes: [] }
|
||||
};
|
||||
|
||||
test("normalization preserves an explicit required action and local password capability", () => {
|
||||
const normalized = normalizeAuthInfo({ ...base, user: { ...base.user, required_auth_action: "change_password", local_password: true } });
|
||||
assert.equal(normalized.user.required_auth_action, "change_password");
|
||||
assert.equal(normalized.user.local_password, true);
|
||||
assert.equal(normalized.scopes.length, 0);
|
||||
});
|
||||
|
||||
test("legacy password-reset metadata remains advisory without the server action", () => {
|
||||
const normalized = normalizeAuthInfo(base);
|
||||
assert.equal(normalized.user.password_reset_required, true);
|
||||
assert.equal(normalized.user.required_auth_action, null);
|
||||
assert.equal(normalized.user.local_password, false);
|
||||
});
|
||||
|
||||
test("lightweight session changes trigger full auth refresh for required actions and provider changes", () => {
|
||||
const auth = normalizeAuthInfo({ ...base, user: { ...base.user, required_auth_action: null, local_password: true } });
|
||||
const session = { user: { ...auth.user }, tenant, active_tenant: tenant, auth_method: "session", session_id: "session-1" };
|
||||
assert.equal(sessionMatchesAuth(session, auth), true);
|
||||
assert.equal(sessionMatchesAuth({ ...session, user: { ...session.user, required_auth_action: "change_password" } }, auth), false);
|
||||
assert.equal(sessionMatchesAuth({ ...session, user: { ...session.user, local_password: false } }, auth), false);
|
||||
assert.equal(sessionMatchesAuth({ ...session, session_id: "rotated-session" }, auth), false);
|
||||
});
|
||||
|
||||
const authorityContext = vm.createContext({ module: { exports: {} } });
|
||||
authorityContext.exports = authorityContext.module.exports;
|
||||
vm.runInContext(transformSync(readFileSync(new URL("../src/api/authAuthority.ts", import.meta.url), "utf8"), { loader: "ts", format: "cjs" }).code, authorityContext);
|
||||
const { authAuthorityKey } = authorityContext.module.exports;
|
||||
const settings = { apiBaseUrl: "https://fixture.invalid", apiKey: "fixture-key", accessToken: "fixture-token" };
|
||||
|
||||
test("authority fences ignore fresh object identity and cosmetic profile changes", () => {
|
||||
const auth = normalizeAuthInfo(base);
|
||||
const key = authAuthorityKey(auth, settings);
|
||||
const refreshed = structuredClone(auth);
|
||||
refreshed.user.display_name = "Changed display name";
|
||||
refreshed.user.preferred_language = "de";
|
||||
refreshed.user.ui_preferences = { compact_tables: true };
|
||||
refreshed.tenant.name = "Changed tenant name";
|
||||
refreshed.profile_loaded = true;
|
||||
assert.equal(authAuthorityKey(refreshed, { ...settings }), key);
|
||||
});
|
||||
|
||||
test("authority fences include principal, tenant, credential, scope, acting and required-action changes", () => {
|
||||
const auth = normalizeAuthInfo(base);
|
||||
const key = authAuthorityKey(auth, settings);
|
||||
for (const mutate of [
|
||||
(value) => { value.user.account_id = "different-account"; },
|
||||
(value) => { value.user.email = "different@example.test"; },
|
||||
(value) => { value.user.is_tenant_admin = true; },
|
||||
(value) => { value.user.required_auth_action = "change_password"; },
|
||||
(value) => { value.user.local_password = !value.user.local_password; },
|
||||
(value) => { value.tenant.id = "different-tenant"; },
|
||||
(value) => { value.active_tenant = { ...value.tenant, id: "active-tenant" }; },
|
||||
(value) => { value.tenant.is_active = false; },
|
||||
(value) => { value.scopes = ["new:permission"]; },
|
||||
(value) => { value.roles = [{ id: "role", slug: "role", permissions: ["new:permission"] }]; },
|
||||
(value) => { value.groups = [{ id: "group" }]; },
|
||||
(value) => { value.principal.session_id = "rotated-session"; },
|
||||
(value) => { value.principal.acting_assignment_id = "assignment"; },
|
||||
(value) => { value.principal.acting_for_account_id = "actor"; },
|
||||
(value) => { value.principal.delegation_ids = ["delegation"]; }
|
||||
]) {
|
||||
const changed = structuredClone(auth);
|
||||
mutate(changed);
|
||||
assert.notEqual(authAuthorityKey(changed, settings), key);
|
||||
}
|
||||
for (const field of ["apiBaseUrl", "apiKey", "accessToken"]) {
|
||||
assert.notEqual(authAuthorityKey(auth, { ...settings, [field]: "changed" }), key);
|
||||
}
|
||||
});
|
||||
|
||||
test("authority set ordering and duplicate entries do not invent a context change", () => {
|
||||
const auth = normalizeAuthInfo(base);
|
||||
auth.scopes = ["b", "a", "b"];
|
||||
auth.principal.group_ids = ["g2", "g1"];
|
||||
const reordered = structuredClone(auth);
|
||||
reordered.scopes = ["a", "b"];
|
||||
reordered.principal.group_ids = ["g1", "g2", "g1"];
|
||||
assert.equal(authAuthorityKey(auth, settings), authAuthorityKey(reordered, settings));
|
||||
});
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { transformSync } = require("esbuild");
|
||||
const React = require("react");
|
||||
const { renderToStaticMarkup } = require("react-dom/server");
|
||||
const { MemoryRouter } = require("react-router");
|
||||
|
||||
function loadSource(path, bindings = {}) {
|
||||
const context = vm.createContext({ module: { exports: {} }, require, ...bindings });
|
||||
context.exports = context.module.exports;
|
||||
vm.runInContext(transformSync(readFileSync(new URL(path, import.meta.url), "utf8"), {
|
||||
loader: path.endsWith(".tsx") ? "tsx" : "ts", format: "cjs", jsx: "automatic"
|
||||
}).code, context);
|
||||
return { exports: context.module.exports, context };
|
||||
}
|
||||
|
||||
const { generatedTranslations } = loadSource("../src/i18n/generatedTranslations.ts").exports;
|
||||
const launch = loadSource("../src/platform/launchContext.ts").exports;
|
||||
|
||||
function harness(locale = "en") {
|
||||
const navigation = [];
|
||||
const translateText = (value) => {
|
||||
if (typeof value === "string") return generatedTranslations[locale][value] ?? value;
|
||||
let text = generatedTranslations[locale][value.key] ?? value.key;
|
||||
for (const [key, replacement] of Object.entries(value.values)) {
|
||||
text = text.replaceAll(`{${key}}`, replacement);
|
||||
}
|
||||
return text;
|
||||
};
|
||||
const loaded = loadSource("../src/layout/BreadcrumbBar.tsx", {
|
||||
window: { history: { state: { idx: 7 } } },
|
||||
require: (name) => {
|
||||
if (name === "../i18n/LanguageContext") return {
|
||||
usePlatformLanguage: () => ({ translateText }),
|
||||
i18nMessage: (key, values) => ({ key, values })
|
||||
};
|
||||
if (name === "../components/UnsavedChangesGuard") return {
|
||||
useGuardedNavigate: () => (...args) => navigation.push(args)
|
||||
};
|
||||
if (name === "../platform/launchContext") return launch;
|
||||
return require(name);
|
||||
}
|
||||
});
|
||||
const BreadcrumbBar = loaded.exports.default;
|
||||
return {
|
||||
...loaded, navigation, BreadcrumbBar,
|
||||
links(pathname, search = "") {
|
||||
const html = renderToStaticMarkup(React.createElement(
|
||||
MemoryRouter, { initialEntries: [`${pathname}${search}`] },
|
||||
React.createElement(BreadcrumbBar, { pathname })
|
||||
));
|
||||
return [...html.matchAll(/<a\b([^>]*)>(.*?)<\/a>/g)].map(([, attributes, label]) => ({
|
||||
href: /href="([^"]*)"/.exec(attributes)?.[1], label: label.replaceAll("&", "&")
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test("module-global reports and operator queue have their own bilingual labels and exact links", () => {
|
||||
for (const locale of ["en", "de"]) {
|
||||
const h = harness(locale);
|
||||
const root = generatedTranslations[locale]["i18n:govoplan-core.campaigns.01a23a28"];
|
||||
for (const [route, key] of [
|
||||
["reports", "i18n:govoplan-core.reports.88bc3fe3"],
|
||||
["queue", "i18n:govoplan-core.operator_queue.72492fb5"]
|
||||
]) {
|
||||
for (const trailingSlash of ["", "/"]) {
|
||||
assert.deepEqual(h.links(`/campaigns/${route}${trailingSlash}`, "?campaign=selected-campaign"), [
|
||||
{ href: "/campaigns", label: root },
|
||||
{ href: `/campaigns/${route}`, label: generatedTranslations[locale][key] }
|
||||
]);
|
||||
}
|
||||
}
|
||||
assert.equal(h.links("/operator")[0].label, generatedTranslations[locale]["i18n:govoplan-core.operator_queue.72492fb5"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("campaign editor report aliases and attachment deep links retain their singular campaign context", () => {
|
||||
const h = harness();
|
||||
const campaignId = "8ef65230-94d1-49a5-93a9-c5ef7c87ad45";
|
||||
for (const suffix of ["report", "reports", "attachments"]) {
|
||||
const links = h.links(`/campaigns/${campaignId}/${suffix}`);
|
||||
assert.deepEqual(links.map((item) => item.href), ["/campaigns", `/campaigns/${campaignId}`, `/campaigns/${campaignId}/${suffix}`]);
|
||||
assert.equal(links[1].label, generatedTranslations.en["i18n:govoplan-core.campaign.69390e16"]);
|
||||
assert.equal(links[2].label, generatedTranslations.en[suffix === "attachments"
|
||||
? "i18n:govoplan-core.attachments.6771ade6" : "i18n:govoplan-core.report.ee45c303"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("quick-access return preserves guarded history back or the exact origin deep link", () => {
|
||||
const origin = launch.createQuickAccessLaunchContext({
|
||||
pathname: "/cases/case-fixture", search: "?tab=work", hash: "#campaigns", historyIndex: 6,
|
||||
auth: { user: { account_id: "account-fixture" }, tenant: { id: "tenant-fixture" } },
|
||||
temporalContext: { validityMode: "current" }
|
||||
});
|
||||
const h = harness();
|
||||
for (const historyIndex of [7, 20]) {
|
||||
h.context.window.history.state.idx = historyIndex;
|
||||
const tree = h.BreadcrumbBar({ pathname: "/campaigns/reports", locationState: launch.quickAccessLaunchState(origin) });
|
||||
const button = React.Children.toArray(tree.props.children).find((item) => item.type === "button");
|
||||
assert.ok(button, "the launch return action remains visible");
|
||||
button.props.onClick();
|
||||
}
|
||||
assert.deepEqual(h.navigation[0], [-1]);
|
||||
assert.equal(h.navigation[1][0], "/cases/case-fixture?tab=work#campaigns");
|
||||
assert.equal(h.navigation[1][1].replace, true);
|
||||
});
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { componentSuites, runComponentTests, selectSuites } from "../scripts/run-component-tests.mjs";
|
||||
|
||||
test("component aliases select the existing contract and reject unknown input", () => {
|
||||
assert.deepEqual(selectSuites([]), Object.keys(componentSuites));
|
||||
assert.deepEqual(selectSuites(["page-layout", "page-layout"]), ["page-layout"]);
|
||||
assert.deepEqual(componentSuites["data-grid-actions"], ["data-grid-actions", "data-grid-sizing"]);
|
||||
assert.throws(() => selectSuites(["../../unowned"]), /Unknown component suite/);
|
||||
});
|
||||
|
||||
test("every configured component test remains covered by the batch and standalone aliases", () => {
|
||||
const config = JSON.parse(readFileSync(new URL("../tsconfig.component-tests.json", import.meta.url), "utf8"));
|
||||
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
const configured = config.include.filter((file) => file.startsWith("tests/")).map((file) => file.replace(/^tests\//, "").replace(/\.test\.tsx?$/, "")).sort();
|
||||
assert.deepEqual(Object.values(componentSuites).flat().sort(), configured);
|
||||
for (const name of Object.keys(componentSuites)) {
|
||||
assert.equal(packageJson.scripts[`test:${name}`], `node scripts/run-component-tests.mjs ${name}`);
|
||||
}
|
||||
assert.equal(packageJson.scripts["test:components"], "node scripts/run-component-tests.mjs");
|
||||
});
|
||||
|
||||
test("one compile serves a batch and preserves structural follow-ups", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "govoplan-component-runner-"));
|
||||
const commands = [];
|
||||
try {
|
||||
const result = await runComponentTests({ webuiRoot: root, compiler: "fixture-tsc", names: ["data-grid-actions", "dialog-focus"], run: async (argv) => commands.push(argv) });
|
||||
assert.equal(result.compiled, 1);
|
||||
assert.equal(commands.filter((argv) => argv[1] === "fixture-tsc").length, 1);
|
||||
assert.equal(commands.length, 5);
|
||||
assert(commands.at(-1)[1].endsWith("test-dialog-focus-structure.mjs"));
|
||||
assert.deepEqual(readdirSync(root), []);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("overlapping runs have separate outputs and failures clean only owned output", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "govoplan-component-runner-"));
|
||||
const outputs = [];
|
||||
let unblock;
|
||||
const bothStarted = new Promise((resolve) => { unblock = resolve; });
|
||||
const run = async (argv) => {
|
||||
if (argv[1] !== "fixture-tsc") return;
|
||||
outputs.push(argv.at(-1));
|
||||
if (outputs.length === 2) unblock();
|
||||
await bothStarted;
|
||||
throw new Error("fixture compilation failed");
|
||||
};
|
||||
try {
|
||||
const results = await Promise.allSettled([1, 2].map(() => runComponentTests({ webuiRoot: root, compiler: "fixture-tsc", names: ["page-layout"], run })));
|
||||
assert(results.every((result) => result.status === "rejected"));
|
||||
assert.equal(new Set(outputs).size, 2);
|
||||
assert.deepEqual(readdirSync(root), []);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -6,9 +6,47 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import Button from "../src/components/Button";
|
||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../src/components/table/DataGrid";
|
||||
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
|
||||
import { dataGridRowIndices } from "../src/components/table/dataGridRowIndices";
|
||||
|
||||
function noop() {}
|
||||
|
||||
const repeatedRow = { label: "same" };
|
||||
const distinctRow = { label: "same" };
|
||||
const identityRows = [repeatedRow, distinctRow, repeatedRow];
|
||||
const identityIndex = dataGridRowIndices(identityRows);
|
||||
assertEqual(identityIndex(repeatedRow), 0, "repeated object references retain their first original index");
|
||||
assertEqual(identityIndex(distinctRow), 1, "equal-looking objects keep distinct identities");
|
||||
assertEqual(identityIndex({ label: "same" }), -1, "absent objects retain indexOf semantics");
|
||||
const primitiveRows = [NaN, -0, 2, 2, 0];
|
||||
const primitiveIndex = dataGridRowIndices(primitiveRows);
|
||||
for (const value of primitiveRows) {
|
||||
assertEqual(primitiveIndex(value), primitiveRows.indexOf(value), "primitive indices preserve NaN, zero and duplicate behavior");
|
||||
}
|
||||
let linearVisits = 0;
|
||||
const measuredRows = Array.from({ length: 4_000 }, (_, index) => ({ index }));
|
||||
measuredRows.forEach = (callback) => Array.prototype.forEach.call(measuredRows, (row, index, array) => {
|
||||
linearVisits += 1;
|
||||
callback(row, index, array);
|
||||
});
|
||||
const measuredIndex = dataGridRowIndices(measuredRows);
|
||||
for (let pass = 0; pass < 10; pass += 1) measuredRows.map(measuredIndex);
|
||||
assertEqual(linearVisits, 4_000, "index construction traverses once, independent of lookup count");
|
||||
|
||||
const callbackIndices: number[] = [];
|
||||
const stableRows = [{ id: "a", rank: 2 }, { id: "b", rank: 1 }, { id: "c", rank: 1 }];
|
||||
const stableMarkup = renderToStaticMarkup(<DataGrid
|
||||
id="stable-original-row-index" rows={stableRows}
|
||||
columns={[{ id: "rank", header: "Rank", sortValue: (row, index) => {
|
||||
assertEqual(stableRows[index], row, "sort callbacks receive original indices");
|
||||
return row.rank;
|
||||
}, render: (row, index) => { callbackIndices.push(index); return row.id; } }]}
|
||||
getRowKey={(row, index) => { assertEqual(stableRows[index], row, "row keys receive original indices"); return row.id; }}
|
||||
initialSort={{ columnId: "rank", direction: "asc" }}
|
||||
/>);
|
||||
assertEqual(stableMarkup.includes("stable-original-row-index"), true, "sorted grid renders");
|
||||
assertEqual(callbackIndices.slice(-3).join(), "1,2,0", "equal sort values remain stable without mutating source order after the unchanged sizing pass");
|
||||
assertEqual(stableRows.map((row) => row.id).join(), "a,b,c", "sorting never reorders input data");
|
||||
|
||||
function buttonCount(markup: string): number {
|
||||
return markup.match(/<button\b/g)?.length ?? 0;
|
||||
}
|
||||
|
||||
@@ -342,6 +342,77 @@ assertWidths(
|
||||
"growth does not resize a fixed peer merely to avoid overflow"
|
||||
);
|
||||
|
||||
const mixedPeerColumns: DataGridSizingColumn[] = [
|
||||
{ id: "first", width: 200, minWidth: 100, maxWidth: 500, resizable: true },
|
||||
{ id: "fixed", width: 120, minWidth: 120, maxWidth: 120 },
|
||||
{ id: "last", width: 300, minWidth: 100, maxWidth: 500, resizable: true },
|
||||
{ id: "actions", width: 100, sticky: "end" }
|
||||
];
|
||||
const mixedPeerBase = { first: 200, fixed: 120, last: 300, actions: 100 };
|
||||
for (const mode of ["cover", "free", "constrained"] as const) {
|
||||
const grown = resizeDataGridColumn(mixedPeerColumns, mixedPeerBase, "first", 80, mode);
|
||||
assertWidths(grown.widths,
|
||||
{ first: 280, fixed: 120, last: mode === "constrained" ? 220 : 300, actions: 100 },
|
||||
`${mode} growth crosses a fixed intermediate column without changing it`);
|
||||
const shrunk = resizeDataGridColumn(mixedPeerColumns, mixedPeerBase, "first", -80, mode);
|
||||
assertWidths(shrunk.widths,
|
||||
{ first: 120, fixed: 120, last: mode === "free" ? 300 : 380, actions: 100 },
|
||||
`${mode} shrink uses only the appropriate resizable compensation peers`);
|
||||
const limited = resizeDataGridColumn(mixedPeerColumns, mixedPeerBase, "first", 800, mode);
|
||||
assertEqual(limited.widths.fixed, 120, `${mode} exhaustion never changes the fixed intermediate track`);
|
||||
assertEqual(limited.widths.first, mode === "constrained" ? 400 : 500, `${mode} retains declared growth limits`);
|
||||
}
|
||||
|
||||
const preferredLimitColumns: DataGridSizingColumn[] = [
|
||||
{ id: "recipients", width: "minmax(320px, 1.4fr)", preferredMaxWidth: 640, resizable: true },
|
||||
{ id: "active", width: 130 },
|
||||
{ id: "delivery", width: "minmax(260px, 0.9fr)", preferredMaxWidth: 480, resizable: true },
|
||||
{ id: "attachments", width: 180 },
|
||||
...["first", "second", "third"].map((id) => ({ id, width: 190, minWidth: 160, preferredMaxWidth: 360, resizable: true })),
|
||||
{ id: "actions", width: 180, sticky: "end" }
|
||||
];
|
||||
const hardLimitColumns = preferredLimitColumns.map(({ preferredMaxWidth, ...column }) => ({ ...column, maxWidth: preferredMaxWidth }));
|
||||
for (const container of [900, 1600, 2400, 3085]) {
|
||||
assertWidths(fitDataGridColumns(preferredLimitColumns, container).widths,
|
||||
fitDataGridColumns(hardLimitColumns, container).widths,
|
||||
`preferred caps preserve the previous automatic fit at ${container}px without imposing manual caps`);
|
||||
}
|
||||
const rightPeersAtOldCaps: DataGridSizingColumn[] = [
|
||||
{ id: "recipients", width: "minmax(320px, 1.4fr)", preferredMaxWidth: 640, resizable: true },
|
||||
{ id: "fixed", width: 130, minWidth: 130, maxWidth: 130 },
|
||||
{ id: "first", width: 190, preferredMaxWidth: 360, resizable: true },
|
||||
{ id: "second", width: 190, preferredMaxWidth: 360, resizable: true },
|
||||
{ id: "actions", width: 180, sticky: "end" }
|
||||
];
|
||||
const oversizedRecipient = { recipients: 1000, fixed: 130, first: 360, second: 360, actions: 180 };
|
||||
const oldManualCaps = rightPeersAtOldCaps.map(({ preferredMaxWidth, ...column }) => ({
|
||||
...column, maxWidth: preferredMaxWidth ?? column.maxWidth
|
||||
}));
|
||||
assertEqual(resizeDataGridColumn(oldManualCaps, oversizedRecipient, "recipients", -120, "cover", 0).appliedDelta, 0,
|
||||
"regression: at the scrolled-right boundary, old field caps block shrinking an oversized recipient column");
|
||||
const shrunkAcrossPreferredCaps = resizeDataGridColumn(rightPeersAtOldCaps, oversizedRecipient, "recipients", -120, "cover", 0);
|
||||
assertWidths(shrunkAcrossPreferredCaps.widths,
|
||||
{ recipients: 880, fixed: 130, first: 420, second: 420, actions: 180 },
|
||||
"preferred field caps allow cover compensation at the right scroll boundary without moving fixed neighbors");
|
||||
assertWidths(fitDataGridColumns(rightPeersAtOldCaps, 2030, {}, shrunkAcrossPreferredCaps.widths, "cover", 2030).widths,
|
||||
shrunkAcrossPreferredCaps.widths, "committing a compensated shrink never refills the wide recipient column");
|
||||
for (const mode of ["cover", "free", "constrained"] as const) {
|
||||
const expandedField = resizeDataGridColumn(rightPeersAtOldCaps, oversizedRecipient, "first", 160, mode);
|
||||
assertEqual(expandedField.widths.first, 520, `${mode} direct resizing can exceed a presentation-only field cap`);
|
||||
assertEqual(expandedField.widths.fixed, 130, `${mode} preferred caps do not change a fixed neighbor`);
|
||||
}
|
||||
const preferredWithHardLimit: DataGridSizingColumn[] = [{ id: "text", width: 300, preferredMaxWidth: 360, maxWidth: 500, resizable: true }];
|
||||
assertEqual(resizeDataGridColumn(preferredWithHardLimit, { text: 360 }, "text", 200, "cover").widths.text, 500,
|
||||
"an explicitly declared hard maximum still bounds direct resizing beyond a preferred cap");
|
||||
assertEqual(fitDataGridColumns([{ id: "text", width: 500, preferredMaxWidth: 400, maxWidth: 300 }], 300, {}, {}, "free").widths.text, 300,
|
||||
"a preferred cap never overrides a smaller hard maximum");
|
||||
assertEqual(fitDataGridColumns([{ id: "text", width: 300, minWidth: 160, preferredMaxWidth: 100 }], 160, {}, {}, "free").widths.text, 160,
|
||||
"preferred caps never reduce a column below its hard minimum");
|
||||
assertEqual(dataGridLayoutSignature([{ id: "text", width: 190, resizable: true }], "container", "cover"),
|
||||
"v3::text:190:::r::::default:::container::cover", "omitting preferred caps preserves the existing persisted signature format");
|
||||
assertEqual(dataGridLayoutSignature(preferredLimitColumns, "container", "cover") === dataGridLayoutSignature(hardLimitColumns, "container", "cover"), false,
|
||||
"new preferred sizing declarations invalidate only their own obsolete manual-width contract");
|
||||
|
||||
const coverBeyondPassiveMax = resizeDataGridColumn([
|
||||
{ id: "first", width: 200, minWidth: 100, maxWidth: 200, resizable: true },
|
||||
{ id: "second", width: 300, minWidth: 100, maxWidth: 300, resizable: true },
|
||||
|
||||
@@ -6,6 +6,14 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import DocumentationHelpLink, { DocumentationHelpProvider } from "../src/components/help/DocumentationHelpLink";
|
||||
import FieldLabel from "../src/components/help/FieldLabel";
|
||||
import { documentationHelpHref } from "../src/components/help/documentationHelp";
|
||||
import PageLayout from "../src/components/PageLayout";
|
||||
import PageTitle from "../src/components/PageTitle";
|
||||
import Card from "../src/components/Card";
|
||||
import Dialog from "../src/components/Dialog";
|
||||
import AdminPageLayout from "../src/components/admin/AdminPageLayout";
|
||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||
import TextWithHelp from "../src/components/help/TextWithHelp";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
|
||||
assert(
|
||||
documentationHelpHref({ topicId: "campaigns.workflow.complete-review" }) ===
|
||||
@@ -49,3 +57,30 @@ const fieldMarkup = renderToStaticMarkup(
|
||||
</DocumentationHelpProvider>
|
||||
);
|
||||
assert(fieldMarkup.includes("topic=access.reference.admin-access-fields"), "field labels can link to stable reference topics");
|
||||
|
||||
for (const language of ["en", "de"]) {
|
||||
const helpLabel = language === "de" ? "Benutzerdokumentation öffnen" : "Open user documentation";
|
||||
const help = <DocumentationHelpLink reference={{ contextId: "dashboard" }} />;
|
||||
for (const [name, element] of [
|
||||
["page", <PageLayout archetype="overview" title="Dashboard" titleHelp={help} headerLoading actions={<button>Configure</button>}>Data</PageLayout>],
|
||||
["title", <PageTitle loading titleHelp={help}>Dashboard</PageTitle>],
|
||||
["administration", <AdminPageLayout title="Dashboard" description="Summary" titleHelp={help}>Data</AdminPageLayout>],
|
||||
["card", <Card title="Dashboard" titleHelp={help} collapsible>Data</Card>],
|
||||
["dialog", <Dialog open title="Dashboard" titleHelp={help} onClose={() => undefined}>Data</Dialog>],
|
||||
["workspace", <WorkspaceActionBar variant="workspace" title="Dashboard" titleHelp={help} titleLevel={1} primaryActions={<button>Edit</button>} />],
|
||||
["section", <TextWithHelp as="div" help={help}><h3>Dashboard</h3></TextWithHelp>]
|
||||
] as const) {
|
||||
const markup = renderToStaticMarkup(<PlatformLanguageProvider preferredLanguageCode={language}>{element}</PlatformLanguageProvider>);
|
||||
const heading = markup.match(/<h[123]\b[^>]*>[\s\S]*?<\/h[123]>/)?.[0];
|
||||
assert(heading?.includes("Dashboard"), `${name} retains a visible semantic heading`);
|
||||
assert(!heading?.includes("documentation-help-link") && !heading?.includes("loading-indicator"), `${name} heading name excludes help and progress controls`);
|
||||
assert(markup.includes(`aria-label="${helpLabel}"`), `${name} keeps translated documentation access in ${language}`);
|
||||
assert(markup.indexOf('class="documentation-help-link"') > markup.indexOf(heading!), `${name} help follows its visible heading`);
|
||||
assert(!/<button\b[^>]*>[\s\S]*?documentation-help-link[\s\S]*?<\/button>/.test(markup), `${name} never nests documentation inside an action`);
|
||||
}
|
||||
}
|
||||
|
||||
const titleWithoutText = renderToStaticMarkup(<WorkspaceActionBar variant="workspace" titleHelp={<DocumentationHelpLink reference={{ contextId: "dashboard" }} />} />);
|
||||
assert(!titleWithoutText.includes("documentation-help-link"), "workspace help cannot appear without a visible context heading");
|
||||
const unheadedCard = renderToStaticMarkup(<Card titleHelp={<DocumentationHelpLink reference={{ contextId: "dashboard" }} />}>Data</Card>);
|
||||
assert(!unheadedCard.includes("documentation-help-link"), "card help cannot appear without a visible title");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AuthInfo,
|
||||
AuthActionUiCapability,
|
||||
DashboardWidgetsUiCapability,
|
||||
OrganizationFunctionActionContext,
|
||||
OrganizationFunctionActionContribution,
|
||||
@@ -80,6 +81,19 @@ for (const testCase of cases) {
|
||||
assert(uiCapability("files.fileExplorer", [access, files]) === filesCapability, "files capability should return the module-provided object");
|
||||
assert(uiCapability("mail.profiles", [access, mail]) === mailCapability, "mail capability should return the module-provided object");
|
||||
|
||||
const authActionCapability: AuthActionUiCapability = {
|
||||
actions: ["change_password"], RequiredAction: () => null, LoginHelp: () => null
|
||||
};
|
||||
const publicAuthModule: PlatformWebModule = {
|
||||
id: "access", label: "Access", version: "test",
|
||||
publicRoutes: [{ path: "/password-recovery", render: () => null }],
|
||||
uiCapabilities: { "auth.actions": authActionCapability }
|
||||
};
|
||||
assert(uiCapability<AuthActionUiCapability>("auth.actions", [publicAuthModule]) === authActionCapability,
|
||||
"required authentication actions can resolve from the public catalogue without normal scopes");
|
||||
assert(uiCapability<AuthActionUiCapability>("auth.actions", []) === null,
|
||||
"core-only compositions have no authentication action implementation");
|
||||
|
||||
const configurableDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
widgets: [
|
||||
{
|
||||
@@ -332,6 +346,28 @@ const aliasOrdered = groupNavigationItems(reorderedProductNavigation.primaryItem
|
||||
assert(aliasOrdered[0]?.items[0]?.to === "/files" && aliasOrdered[1]?.items[0]?.to === "/messages" && aliasOrdered[1]?.label === "View group", "View ordering recognizes non-placement owner aliases and assigns the requested separator exactly once");
|
||||
const unauthorizedLockedOwner = projectProductNavigation(reorderedOwners, messageSurfaceModules, { scopes: ["mail:mailbox:read"] } as AuthInfo).primaryItems.find((item) => item.to === "/messages");
|
||||
assert(unauthorizedLockedOwner?.navigationLocked === false && !unauthorizedLockedOwner.navigationAliases?.includes("postbox.navigation.postbox"), "unavailable optional owners contribute neither aliases nor lock metadata");
|
||||
const composedCommunicationArea = [{
|
||||
id: "communication", moduleId: "mail", label: "Communication", iconName: "mail" as const,
|
||||
surfaceIds: ["mail.navigation.mail"], order: 40
|
||||
}];
|
||||
const defaultComposedNavigation = reorderedProductNavigation.primaryItems.map((item) => ({
|
||||
...item, navigationCustomLayout: false, navigationSection: null
|
||||
}));
|
||||
assert(groupNavigationItems(defaultComposedNavigation, composedCommunicationArea)[0]?.items[0]?.to === "/messages", "standard product-area grouping recognizes authorized composed-owner aliases even when another owner supplies the rail placement");
|
||||
assert(groupNavigationItems(defaultComposedNavigation, composedCommunicationArea)[0]?.label === "Communication", "composed entries retain their standard section heading without a saved layout");
|
||||
const competingOwnerAreas = [
|
||||
{ ...composedCommunicationArea[0], order: 10 },
|
||||
{ ...composedCommunicationArea[0], id: "direct-owner", label: "Direct owner area", surfaceIds: ["postbox.navigation.postbox"], order: 70 }
|
||||
];
|
||||
const directOwnerGroups = groupNavigationItems(defaultComposedNavigation, competingOwnerAreas);
|
||||
assert(directOwnerGroups[0]?.id === "product-area:direct-owner" && directOwnerGroups[0]?.items[0]?.to === "/messages", "an authorized composed entry keeps its directly declared area even when another owner's alias belongs to an earlier area");
|
||||
assert(directOwnerGroups.flatMap((group) => group.items).filter((item) => item.to === "/messages").length === 1, "competing owner areas never duplicate the composed destination");
|
||||
const personalFlatNavigation = defaultComposedNavigation.map((item) => ({
|
||||
...item, navigationCustomLayout: true, navigationLayoutSource: "user"
|
||||
}));
|
||||
assert(groupNavigationItems(personalFlatNavigation, composedCommunicationArea).every((group) => group.label === undefined), "standard sections never replace an explicitly flat personal layout");
|
||||
const mailOnlyDefaultNavigation = projectProductNavigation(reorderedOwners, messageSurfaceModules, { scopes: ["mail:mailbox:read"] } as AuthInfo).primaryItems;
|
||||
assert(groupNavigationItems(mailOnlyDefaultNavigation, [{ ...composedCommunicationArea[0], surfaceIds: ["postbox.navigation.postbox"] }])[0]?.id === "more-tools", "an unauthorized optional owner's area alias cannot affect default navigation grouping");
|
||||
assert(
|
||||
mailOnlyNavigation.primaryItems.map((item) => item.to).join(",") === "/messages",
|
||||
"composition should remain stable when only one optional contributor is authorized"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { importModuleWithRetry } from "../src/platform/moduleLoading";
|
||||
import { createModuleRefresh } from "../src/platform/moduleRefresh";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -34,3 +35,48 @@ async function verifyModuleImportRetry() {
|
||||
}
|
||||
|
||||
void verifyModuleImportRetry().catch((error) => { throw error; });
|
||||
|
||||
async function verifyModuleRefresh() {
|
||||
const requests: Array<{ resolve: (value: number) => void; reject: (error: unknown) => void }> = [];
|
||||
const accepted: number[] = [];
|
||||
const errors: unknown[] = [];
|
||||
let time = 0;
|
||||
const refresh = createModuleRefresh({
|
||||
load: () => new Promise<number>((resolve, reject) => requests.push({ resolve, reject })),
|
||||
accept: (value) => accepted.push(value), reject: (error) => errors.push(error), now: () => time
|
||||
});
|
||||
const settle = async () => { await Promise.resolve(); await Promise.resolve(); };
|
||||
refresh.invalidate();
|
||||
refresh.invalidate();
|
||||
refresh.invalidate();
|
||||
assert(requests.length === 1, "mutation bursts cannot start overlapping requests");
|
||||
requests[0].resolve(0);
|
||||
await settle();
|
||||
assert(accepted.length === 0 && Number(requests.length) === 2, "obsolete success is ignored and invalidation gets one trailing read");
|
||||
refresh.invalidate();
|
||||
requests[1].reject(new Error("obsolete"));
|
||||
await settle();
|
||||
assert(errors.length === 0 && Number(requests.length) === 3, "obsolete error cannot clear the current catalogue or drop invalidation");
|
||||
requests[2].resolve(2);
|
||||
await settle();
|
||||
assert(accepted.join() === "2", "only the current generation publishes");
|
||||
refresh.refreshVisible(true);
|
||||
time = 6_000;
|
||||
refresh.refreshVisible(false);
|
||||
assert(Number(requests.length) === 3, "focus throttle and hidden-document checks remain");
|
||||
refresh.refreshVisible(true);
|
||||
assert(Number(requests.length) === 4, "visible focus refresh remains available after the throttle");
|
||||
requests[3].reject("current failure");
|
||||
await settle();
|
||||
assert(errors.join() === "current failure", "current errors retain fail-closed handling without automatic retries");
|
||||
refresh.invalidate();
|
||||
refresh.invalidate();
|
||||
refresh.dispose();
|
||||
requests[4].resolve(4);
|
||||
await settle();
|
||||
refresh.invalidate();
|
||||
assert(Number(requests.length) === 5 && accepted.join() === "2", "authority change/unmount discards both old response and queued work");
|
||||
console.log("Module refresh coalescing and generation tests passed.");
|
||||
}
|
||||
|
||||
void verifyModuleRefresh().catch((error) => { throw error; });
|
||||
|
||||
@@ -91,3 +91,34 @@ const delegatedHeaderMarkup = renderToStaticMarkup(
|
||||
|
||||
assert(!delegatedHeaderMarkup.includes("page-layout-header"), "composite workspaces can delegate their visible heading to contributed content");
|
||||
assert(delegatedHeaderMarkup.includes("page-layout-workspace content-pad workspace-data-page"), "headerless composite pages retain the central content frame");
|
||||
|
||||
for (const state of ["clean", "dirty", "invalid", "save-failed", "conflict", "saving"] as const) {
|
||||
for (const behavior of ["reset", "exit"] as const) {
|
||||
const markup = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider>
|
||||
<PageActionBar variant="editor" state={state}
|
||||
discardAction={{ label: "Cancel", behavior, "aria-label": "test-cancel" }}
|
||||
saveAction={{ label: "Save", "aria-label": "test-save" }} />
|
||||
</PlatformLanguageProvider>
|
||||
);
|
||||
const cancelButton = markup.match(/<button\b[^>]*aria-label="test-cancel"[^>]*>/)?.[0];
|
||||
const saveButton = markup.match(/<button\b[^>]*aria-label="test-save"[^>]*>/)?.[0];
|
||||
assert(cancelButton && saveButton, "editor actions remain visible in every draft state");
|
||||
const disabled = (button: string | undefined) => /\bdisabled=""/.test(button ?? "");
|
||||
assert(disabled(cancelButton) === (state === "saving" || (state === "clean" && behavior === "reset")),
|
||||
`${behavior} action has the correct disabled state when ${state}`);
|
||||
assert(disabled(saveButton) === ["clean", "invalid", "saving"].includes(state),
|
||||
`cancel availability must not enable invalid/no-op/concurrent saving when ${state}`);
|
||||
assert(!markup.includes('behavior="'), "the action contract must not leak non-HTML attributes");
|
||||
}
|
||||
}
|
||||
|
||||
const permissionBlockedExit = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider>
|
||||
<PageActionBar variant="editor" state="clean"
|
||||
discardAction={{ label: "Cancel", behavior: "exit", disabled: true, disabledReason: "Explicitly blocked", "aria-label": "test-blocked-cancel" }}
|
||||
saveAction={{ label: "Save" }} />
|
||||
</PlatformLanguageProvider>
|
||||
);
|
||||
assert(/\bdisabled=""/.test(permissionBlockedExit.match(/<button\b[^>]*aria-label="test-blocked-cancel"[^>]*>/)?.[0] ?? ""),
|
||||
"exit semantics preserve an explicit owning-page blocker");
|
||||
|
||||
@@ -38,7 +38,10 @@ const markup = renderToStaticMarkup(
|
||||
);
|
||||
|
||||
assert(markup.includes('role="listbox"'), "the root exposes listbox semantics");
|
||||
assert(markup.includes('class="selection-list domain-list"'), "root caller classes are preserved");
|
||||
const rootClasses = markup.match(/\bclass="([^"]+)"/)?.[1].split(/\s+/) ?? [];
|
||||
assert(rootClasses.includes("selection-list"), "the root retains its shared component class");
|
||||
assert(rootClasses.includes("selection-list-plain"), "the root defaults to the plain variant");
|
||||
assert(rootClasses.includes("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");
|
||||
@@ -59,6 +62,19 @@ const nativeLabelMarkup = renderToStaticMarkup(
|
||||
);
|
||||
assert(nativeLabelMarkup.includes('aria-label="Already translated"'), "native accessible labels pass through unchanged");
|
||||
|
||||
const navigationMarkup = renderToStaticMarkup(
|
||||
<SelectionList variant="navigation" className="domain-navigation" aria-label="Navigation">
|
||||
<SelectionListItem selected>Current section</SelectionListItem>
|
||||
</SelectionList>
|
||||
);
|
||||
const navigationClasses = navigationMarkup.match(/\bclass="([^"]+)"/)?.[1].split(/\s+/) ?? [];
|
||||
assert(navigationClasses.includes("selection-list"), "navigation retains the shared root class");
|
||||
assert(navigationClasses.includes("selection-list-navigation"), "the navigation variant is explicit");
|
||||
assert(!navigationClasses.includes("selection-list-plain"), "navigation does not inherit the plain variant class");
|
||||
assert(navigationClasses.includes("domain-navigation"), "navigation preserves caller classes");
|
||||
assert(navigationMarkup.includes('role="listbox"'), "navigation retains listbox semantics");
|
||||
assert(navigationMarkup.includes('aria-label="Navigation"'), "navigation retains its accessible label");
|
||||
|
||||
const directoryOptions = [
|
||||
{
|
||||
value: "account-1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@govoplan/core-webui": ["./conformance/QuickAccessCoreFacade.ts"],
|
||||
|
||||
Reference in New Issue
Block a user