13 Commits

35 changed files with 930 additions and 617 deletions

View File

@@ -20,7 +20,10 @@ database_url = config.attributes.get("database_url") or settings.database_url
config.set_main_option("sqlalchemy.url", database_url) config.set_main_option("sqlalchemy.url", database_url)
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) # Migrations can run inside the long-lived application process when module
# state changes. Do not let Alembic's logging setup disable loggers that the
# server already created (for example slow-request diagnostics).
fileConfig(config.config_file_name, disable_existing_loggers=False)
def _target_metadata(): def _target_metadata():

View File

@@ -126,6 +126,15 @@ Feature modules should prefer these capabilities over direct reads of
access/tenant ORM models when they need labels, group membership, default access/tenant ORM models when they need labels, group membership, default
access provisioning, counts, audit actor labels, or tenant metadata. access provisioning, counts, audit actor labels, or tenant metadata.
Other stable runtime capabilities currently include:
- `identity.directory` and `identity.search`
- `organizations.directory`
- `idm.directory`
- `calendar.outbox` and `calendar.scheduling`
- `poll.scheduling`
- `notifications.dispatch`
### Named Interface Contracts ### Named Interface Contracts
Capabilities are runtime objects. Named interface contracts are compatibility Capabilities are runtime objects. Named interface contracts are compatibility
@@ -147,15 +156,25 @@ intended for SemVer major-version lines. Missing optional interfaces are
allowed, but an installed provider with an incompatible version blocks allowed, but an installed provider with an incompatible version blocks
activation because the integration would otherwise bind to an unsafe API. activation because the integration would otherwise bind to an unsafe API.
Current named interfaces: Current named interfaces, generated from the source manifests by the workspace
contract checks, are:
- `files.campaign_attachments` - `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
- `calendar.outbox`, `calendar.scheduling`
- `campaigns.access`, `campaigns.delivery_tasks`,
`campaigns.mail_policy_context`, `campaigns.policy_context`,
`campaigns.retention`
- `dist_lists.expand`, `dist_lists.source`, `dist_lists.writer`
- `evaluation.feedback`, `evaluation.result_aggregation`, `evaluation.scoring`
- `files.access`, `files.campaign_attachments`
- `mail.campaign_delivery` - `mail.campaign_delivery`
- `campaigns.access` - `notifications.dispatch`
- `campaigns.delivery_tasks` - `poll.availability_matrix`, `poll.option_selection`,
- `campaigns.mail_policy_context` `poll.response_collection`, `poll.signed_participation`,
- `campaigns.policy_context` `poll.workflow_context`
- `campaigns.retention` - `rest.function_publication`
- `scheduling.candidate_slots`, `scheduling.decision_handoff`
- `soap.operation_publication`
Core validates named interface contracts in three places: Core validates named interface contracts in three places:

View File

@@ -40,17 +40,26 @@ cd /mnt/DATA/git/govoplan
Update those refs when cutting a release: Update those refs when cutting a release:
```text ```text
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.6 govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.6 govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.6 govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.6 govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.6 govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.6 govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.6 govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.6 govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.6 govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.6 govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.6 govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.8
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.8
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.8
govoplan-poll git@git.add-ideas.de:add-ideas/govoplan-poll.git v0.1.8
govoplan-scheduling git@git.add-ideas.de:add-ideas/govoplan-scheduling.git v0.1.8
govoplan-notifications git@git.add-ideas.de:add-ideas/govoplan-notifications.git v0.1.8
govoplan-evaluation git@git.add-ideas.de:add-ideas/govoplan-evaluation.git v0.1.8
govoplan-docs git@git.add-ideas.de:add-ideas/govoplan-docs.git v0.1.8
govoplan-ops git@git.add-ideas.de:add-ideas/govoplan-ops.git v0.1.8
``` ```
## WebUI Packages ## WebUI Packages
@@ -70,18 +79,18 @@ cd /mnt/DATA/git/govoplan-core/webui
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
``` ```
The module repositories include root-level npm package manifests so git Module repositories with a frontend include root-level npm package manifests
installs can resolve `@govoplan/access-webui`, `@govoplan/admin-webui`, so the `@govoplan/*-webui` dependencies in `webui/package.release.json` can be
`@govoplan/files-webui`, `@govoplan/mail-webui`, resolved from repository roots even though their source lives below
`@govoplan/campaign-webui`, and `@govoplan/calendar-webui` from repository `webui/src`.
roots even though their source lives below `webui/src`.
### Release Lockfile Strategy ### Release Lockfile Strategy
The supported release composition currently is the full GovOPlaN product: core The supported backend release composition is the set pinned in the meta
plus access, admin, tenancy, organizations, identity, policy, audit, repository's `requirements-release.txt`. The supported frontend composition
dashboard, files, mail, campaign, calendar, docs, and ops. Keep one committed is the independently buildable module set pinned in
full-product release lockfile at `webui/package.release.json`; backend-only modules do not need a frontend
package entry. Keep one committed full-product release lockfile at
`webui/package-lock.release.json`, generated from `webui/package-lock.release.json`, generated from
`webui/package.release.json` in a clean release workspace. Development `webui/package.release.json` in a clean release workspace. Development
`package-lock.json` may continue to point at local `file:` dependencies. `package-lock.json` may continue to point at local `file:` dependencies.
@@ -104,7 +113,7 @@ working tree has already been bumped, pass the current version explicitly:
```bash ```bash
cd /mnt/DATA/git/govoplan cd /mnt/DATA/git/govoplan
tools/release/push-release-tag.sh --version 0.1.6 tools/release/push-release-tag.sh --version 0.1.8
``` ```
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered `/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
@@ -134,13 +143,11 @@ Current tag-only module repositories:
- `govoplan-fit-connect` - `govoplan-fit-connect`
- `govoplan-forms` - `govoplan-forms`
- `govoplan-identity-trust` - `govoplan-identity-trust`
- `govoplan-idm`
- `govoplan-ledger` - `govoplan-ledger`
- `govoplan-notifications`
- `govoplan-payments` - `govoplan-payments`
- `govoplan-portal` - `govoplan-portal`
- `govoplan-postbox`
- `govoplan-reporting` - `govoplan-reporting`
- `govoplan-scheduling`
- `govoplan-search` - `govoplan-search`
- `govoplan-tasks` - `govoplan-tasks`
- `govoplan-templates` - `govoplan-templates`

View File

@@ -43,6 +43,9 @@ contestability, responsibility, and traceability at the point of action.
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables | | UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces | | UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition | | UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
## Confirmed Implementation Decisions ## Confirmed Implementation Decisions
@@ -160,6 +163,50 @@ shared CSS tokens and persisted user preference selection.
- Visual preview in settings is illustrative; it must reflect token families, - Visual preview in settings is illustrative; it must reflect token families,
not become a second theme implementation. not become a second theme implementation.
### DUE-009: Central Component And Exception Contract
Decision: module interfaces are compositions of the components exported by
`@govoplan/core-webui`. When Core already owns the matching interaction, using
the central component is required rather than preferred.
A route or domain-specific page composed from central components is ordinary
module composition. A new reusable UI control, presentation primitive, or
module-local substitute is a custom component. Before one is implemented, the
product owner must explicitly authorize it and the owning decision or issue must
record:
- its single, narrowly defined purpose and intended consumers
- why central components or their composition cannot meet that purpose
- the permitted scope and the boundary it must not grow beyond
- accessibility, reachable states, theme behavior, and test expectations
- whether the component remains domain-owned or is a candidate for Core
Custom components must not duplicate, fork, or cosmetically replace a central
component. An existing local implementation does not grant an exception. If a
central contract later covers the need, migrate to it unless the product owner
explicitly retains the exception.
### DUE-010: Scheduling Request Reference Composition
Decision: Scheduling requests provide a concrete reference application of the
universal placement and component rules.
- The `Scheduling requests` header owns one `Add` action.
- Its left panel is used for the creation view only, not as a permanent second
creation affordance.
- Basic information, Calendar integration, candidate slots, and participants
use the central `Card` component as four logical sections.
- Candidate slots and participants use the central `DataGrid`, including its
standard row-action placement and order.
- Calendar integration uses the central `ToggleSwitch`, with its dependent
controls shown when enabled.
- Each participant is edited as one structured row with name, email address,
and actions. The normal editor does not parse a free-form list of addresses;
that interaction requires a separate, explicitly designed bulk-import flow.
Equivalent list/create/edit surfaces use the same underlying rules. These are
not Scheduling-local component variants.
## Implementation Sequence ## Implementation Sequence
| Phase | Scope | Output | | Phase | Scope | Output |
@@ -222,6 +269,14 @@ Every new or changed admin/configuration surface should answer:
- Does the action surface show consequence, reversibility, and audit evidence - Does the action surface show consequence, reversibility, and audit evidence
when rights, duties, records, money, communication, external systems, or when rights, duties, records, money, communication, external systems, or
workflow state are affected? workflow state are affected?
- Does the surface use every applicable central Core component? If it contains
a custom component, is the product-owner authorization, narrow purpose,
rationale, scope, and non-duplication evidence recorded?
- Is a collection-wide create action in the collection heading rather than
duplicated in a persistent side panel?
- Are logical sections, tabular collections, boolean settings, and repeatable
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
structured row per person respectively?
- If automation is involved, can the user see the trigger, system actor, - If automation is involved, can the user see the trigger, system actor,
observed effects, and failure/manual-intervention state? observed effects, and failure/manual-intervention state?
- Are technical details available without being the first thing the user sees? - Are technical details available without being the first thing the user sees?

View File

@@ -83,6 +83,90 @@
"release": "0.1.7", "release": "0.1.7",
"track": "release", "track": "release",
"squash_policy": "reviewed-manual" "squash_policy": "reviewed-manual"
},
{
"heads": [
{
"owner": "govoplan-campaign",
"revision": "2c3d4e5f7081"
},
{
"owner": "govoplan-mail",
"revision": "3d4e5f708192"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-calendar",
"revision": "9e0f1a2b3c4d"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"9e0f1a2b3c4d"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"2c3d4e5f7081"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"3d4e5f708192"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
}
],
"recorded_at": "2026-07-20T02:45:41Z",
"release": "0.1.8",
"track": "release",
"squash_policy": "reviewed-manual"
} }
], ],
"version": 1 "version": 1

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-core" name = "govoplan-core"
version = "0.1.8" version = "0.1.9"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View File

@@ -121,7 +121,9 @@ def deliver_notification(self, notification_id: str):
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session: with get_database().SessionLocal() as session:
return dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id)) result = dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
session.commit()
return result
@celery.task(name="govoplan.notifications.deliver_pending", bind=True, max_retries=0) @celery.task(name="govoplan.notifications.deliver_pending", bind=True, max_retries=0)
@@ -129,7 +131,9 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session: with get_database().SessionLocal() as session:
return dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit)) result = dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0) @celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)

View File

@@ -7,6 +7,7 @@ from typing import Literal, Protocol, runtime_checkable
IDENTITY_MODULE_ID = "identity" IDENTITY_MODULE_ID = "identity"
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory" CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
CAPABILITY_IDENTITY_SEARCH = "identity.search"
IdentityStatus = Literal["active", "inactive", "suspended"] IdentityStatus = Literal["active", "inactive", "suspended"]
@@ -44,3 +45,15 @@ class IdentityDirectory(Protocol):
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]: def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
... ...
@runtime_checkable
class IdentitySearchProvider(Protocol):
def search_identities(
self,
query: str | None = None,
*,
include_inactive: bool = False,
limit: int = 25,
) -> Sequence[IdentityRef]:
...

View File

@@ -5,8 +5,10 @@ from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_chec
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"] PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention" CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign") POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
@@ -126,6 +128,55 @@ class PolicyDecision:
} }
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyRequest:
"""Context for resolving what one Scheduling participant may see.
``requested_visibility`` is the Scheduling-owned configuration result. A
policy provider may preserve or reduce it, but must not broaden it. Core
deliberately defines no fallback when the optional capability is absent;
that secure default remains the responsibility of Scheduling.
"""
tenant_id: str
scheduling_request_id: str
participant_id: str
requested_visibility: SchedulingParticipantVisibility
actor_user_id: str | None = None
context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyDecision:
"""Policy-resolved visibility of other participants' names and statuses."""
effective_visibility: SchedulingParticipantVisibility
reason: str | None = None
source_path: tuple[PolicySourceStep, ...] = ()
details: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"effective_visibility": self.effective_visibility,
"reason": self.reason,
"source_path": [step.to_dict() for step in self.source_path],
"details": dict(self.details),
}
@runtime_checkable
class SchedulingParticipantPrivacyPolicy(Protocol):
"""Optional policy hook for Scheduling participant-roster disclosure."""
def resolve_scheduling_participant_visibility(
self,
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
...
@runtime_checkable @runtime_checkable
class PrivacyRetentionService(Protocol): class PrivacyRetentionService(Protocol):
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any: def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
@@ -160,3 +211,16 @@ class PrivacyRetentionService(Protocol):
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any: def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
... ...
def scheduling_participant_privacy_policy(
registry: object | None,
) -> SchedulingParticipantPrivacyPolicy | None:
"""Return the optional provider without selecting a visibility fallback."""
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
return None
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
return capability if isinstance(capability, SchedulingParticipantPrivacyPolicy) else None

View File

@@ -58,6 +58,21 @@ class PollUpdateCommand:
closes_at: datetime | None closes_at: datetime | None
@dataclass(frozen=True, slots=True)
class PollOptionUpdateCommand:
"""Complete mutable option snapshot supplied by an owning module.
Providers must treat an exact repeat as a no-op. When the snapshot
changes, answers for this option are invalidated while answers for other
options are retained.
"""
label: str
description: str | None = None
value: Mapping[str, object] | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PollInvitationCommand: class PollInvitationCommand:
respondent_id: str | None = None respondent_id: str | None = None
@@ -102,6 +117,14 @@ class PollInvitationRef:
token: str token: str
@dataclass(frozen=True, slots=True)
class PollAnswerRef:
option_id: str | None = None
option_key: str | None = None
value: object = None
rank: int | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PollResponseRef: class PollResponseRef:
invitation_id: str | None invitation_id: str | None
@@ -110,6 +133,9 @@ class PollResponseRef:
# consumer can use it to reconcile an authenticated response without # consumer can use it to reconcile an authenticated response without
# trusting client-supplied invitation metadata. # trusting client-supplied invitation metadata.
respondent_id: str | None = None respondent_id: str | None = None
# Optional for backwards compatibility. Providers that support response
# editing expose the authoritative, still-valid answers here.
answers: tuple[PollAnswerRef, ...] = ()
@runtime_checkable @runtime_checkable
@@ -144,6 +170,19 @@ class PollSchedulingProvider(Protocol):
) -> PollRef: ) -> PollRef:
... ...
def update_option(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
option_id: str,
command: PollOptionUpdateCommand,
) -> PollOptionRef:
"""Update one option and invalidate only answers for that option."""
...
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef: def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
... ...
@@ -183,6 +222,19 @@ class PollSchedulingProvider(Protocol):
def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> Sequence[PollResponseRef]: def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> Sequence[PollResponseRef]:
... ...
def get_response(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
respondent_ids: Sequence[str],
invitation_id: str | None = None,
) -> PollResponseRef | None:
"""Return one response matching server-trusted participant identities."""
...
@runtime_checkable @runtime_checkable
class PollResponseSubmissionProvider(Protocol): class PollResponseSubmissionProvider(Protocol):

View File

@@ -83,7 +83,6 @@ from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY,
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.tenancy.scope import create_scope_tables from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
@@ -1382,6 +1381,8 @@ class AccessContractTests(unittest.TestCase):
app_configurators=(), app_configurators=(),
) )
with temporary_database(f"sqlite:///{root / 'test.db'}") as database: with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
from govoplan_core.server.app import create_app
app = create_app(config) app = create_app(config)
create_scope_tables(database.engine) create_scope_tables(database.engine)

View File

@@ -26,10 +26,21 @@ os.environ["FILE_STORAGE_LOCAL_ROOT"] = str(_TEST_ROOT / "files")
os.environ["MOCK_MAILBOX_DIR"] = str(_TEST_ROOT / "mock-mailbox") os.environ["MOCK_MAILBOX_DIR"] = str(_TEST_ROOT / "mock-mailbox")
os.environ["DEV_BOOTSTRAP_ENABLED"] = "false" os.environ["DEV_BOOTSTRAP_ENABLED"] = "false"
from govoplan_core.settings import Settings, settings as core_settings
# unittest discovery imports other contract modules before this smoke module.
# Refresh the shared settings object in place so modules that already imported
# it observe this test's isolated environment before the default app is built.
_isolated_settings = Settings()
for _field_name in Settings.model_fields:
setattr(core_settings, _field_name, getattr(_isolated_settings, _field_name))
from alembic import command
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.bootstrap import bootstrap_dev_data from govoplan_core.db.bootstrap import bootstrap_dev_data
from govoplan_core.db.migrations import alembic_config
from govoplan_core.db.session import configure_database, set_database from govoplan_core.db.session import configure_database, set_database
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint
@@ -61,6 +72,16 @@ class ApiSmokeTests(unittest.TestCase):
Base.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine)
create_scope_tables(engine) create_scope_tables(engine)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# This suite builds the current ORM schema directly. Record the matching
# release heads so module-lifecycle tests can safely run Alembic without
# misclassifying the create_all database as a historic dev revision.
if not getattr(type(self), "_migration_heads_stamped", False):
command.stamp(
alembic_config(database_url=os.environ["DATABASE_URL"]),
"heads",
purge=True,
)
type(self)._migration_heads_stamped = True
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True) shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True) shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True)
with SessionLocal() as session: with SessionLocal() as session:
@@ -310,6 +331,7 @@ class ApiSmokeTests(unittest.TestCase):
roles = self.client.get("/api/v1/admin/system/roles", headers=headers) roles = self.client.get("/api/v1/admin/system/roles", headers=headers)
self.assertEqual(roles.status_code, 200, roles.text) self.assertEqual(roles.status_code, 200, roles.text)
system_admin = next(item for item in roles.json()["roles"] if item["slug"] == "system_admin") system_admin = next(item for item in roles.json()["roles"] if item["slug"] == "system_admin")
maintenance_operator = next(item for item in roles.json()["roles"] if item["slug"] == "maintenance_operator")
created = self.client.post( created = self.client.post(
"/api/v1/admin/system/accounts", "/api/v1/admin/system/accounts",
headers=headers, headers=headers,
@@ -319,7 +341,7 @@ class ApiSmokeTests(unittest.TestCase):
"password": "approver-password", "password": "approver-password",
"password_reset_required": False, "password_reset_required": False,
"is_active": True, "is_active": True,
"role_ids": [system_admin["id"]], "role_ids": [system_admin["id"], maintenance_operator["id"]],
"memberships": [{ "memberships": [{
"tenant_id": tenant_id, "tenant_id": tenant_id,
"is_active": True, "is_active": True,
@@ -3432,6 +3454,127 @@ class ApiSmokeTests(unittest.TestCase):
"202605-010001-90100010-9601741.XLSX", "202605-010001-90100010-9601741.XLSX",
}) })
def test_managed_attachment_unlinked_candidates_can_be_linked_on_validation(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
campaign_json = {
"version": "1.0",
"campaign": {"id": "managed-unlinked-attachments", "name": "Managed unlinked attachments", "mode": "test"},
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
"global_values": {},
"server": {
"smtp": {
"host": "smtp.example.invalid",
"port": 587,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
}
},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
},
"template": {"subject": "Invoice", "text": "Please see the attached file."},
"attachments": {
"base_path": "invoices",
"base_paths": [
{
"id": "personal-invoices",
"name": "My invoices",
"source": f"managed:user:{user_id}",
"path": "invoices",
"allow_individual": True,
}
],
"global": [],
"allow_individual": True,
},
"entries": {
"inline": [
{
"id": "recipient-1",
"to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}],
"fields": {"invoice_number": "202605-010002"},
"attachments": [
{
"id": "exact-pattern",
"label": "Exact workbook",
"base_path_id": "personal-invoices",
"base_dir": "invoices",
"file_filter": "{{local:invoice_number}}.XLSX",
"required": True,
},
],
}
]
},
"validation_policy": {
"missing_email": "block",
"template_error": "block",
"missing_required_attachment": "block",
},
"delivery": {"imap_append_sent": {"enabled": False}},
}
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
self.assertEqual(created.status_code, 200, created.text)
campaign_id = created.json()["campaign"]["id"]
version_id = created.json()["version"]["id"]
uploaded = self.client.post(
"/api/v1/files/upload",
headers=headers,
data={
"owner_type": "user",
"owner_id": user_id,
"path": "invoices",
},
files=[("files", ("202605-010002.XLSX", b"unlinked workbook", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))],
)
self.assertEqual(uploaded.status_code, 200, uploaded.text)
preview = self.client.post(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
headers=headers,
json={"include_unmatched": True, "include_unlinked_candidates": True},
)
self.assertEqual(preview.status_code, 200, preview.text)
preview_payload = preview.json()
self.assertEqual(preview_payload["matched_file_count"], 1)
self.assertEqual(preview_payload["unlinked_file_count"], 1)
self.assertEqual(preview_payload["rules"][0]["unlinked_match_count"], 1)
self.assertFalse(preview_payload["rules"][0]["matches"][0]["linked_to_campaign"])
dry_run = self.client.post(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/link-matches",
headers=headers,
json={"dry_run": True},
)
self.assertEqual(dry_run.status_code, 200, dry_run.text)
self.assertEqual(dry_run.json()["linked_file_count"], 0)
self.assertEqual(len(dry_run.json()["linkable_files"]), 1)
validated = self.client.post(
f"/api/v1/campaigns/versions/{version_id}/validate",
headers=headers,
json={"check_files": True, "link_unshared_matches": True},
)
self.assertEqual(validated.status_code, 200, validated.text)
self.assertTrue(validated.json()["ok"], validated.text)
linked_preview = self.client.post(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
headers=headers,
json={"include_unmatched": True, "include_unlinked_candidates": True},
)
self.assertEqual(linked_preview.status_code, 200, linked_preview.text)
linked_payload = linked_preview.json()
self.assertEqual(linked_payload["matched_file_count"], 1)
self.assertEqual(linked_payload["linked_file_count"], 1)
self.assertEqual(linked_payload["unlinked_file_count"], 0)
self.assertTrue(linked_payload["rules"][0]["matches"][0]["linked_to_campaign"])
def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None: def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None:
headers, login = self._login() headers, login = self._login()
user_id = login["user"]["id"] user_id = login["user"]["id"]
@@ -5648,7 +5791,7 @@ class ApiSmokeTests(unittest.TestCase):
password="reader-password", password="reader-password",
role_ids=[str(access_role["id"])], role_ids=[str(access_role["id"])],
) )
other = self._create_user( self._create_user(
owner_headers, owner_headers,
email="campaign-other@example.local", email="campaign-other@example.local",
password="other-password", password="other-password",

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -39,6 +40,22 @@ def database_migration_heads(connection) -> set[str]:
class DatabaseMigrationTests(unittest.TestCase): class DatabaseMigrationTests(unittest.TestCase):
def test_migration_logging_keeps_application_loggers_enabled(self) -> None:
logger = logging.getLogger("govoplan.request")
previous_disabled = logger.disabled
logger.disabled = False
try:
with tempfile.TemporaryDirectory(prefix="govoplan-migration-logging-test-") as directory:
database = Path(directory) / "logging.db"
command.stamp(
alembic_config(database_url=f"sqlite:///{database}", enabled_modules=()),
"heads",
)
self.assertFalse(logger.disabled)
finally:
logger.disabled = previous_disabled
def test_migration_tracks_use_separate_version_locations(self) -> None: def test_migration_tracks_use_separate_version_locations(self) -> None:
release_locations = alembic_config(database_url="sqlite:////tmp/govoplan-release.db").get_main_option("version_locations") release_locations = alembic_config(database_url="sqlite:////tmp/govoplan-release.db").get_main_option("version_locations")
dev_locations = alembic_config( dev_locations = alembic_config(
@@ -184,6 +201,7 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertIsNone(result.reconciled_revision) self.assertIsNone(result.reconciled_revision)
self.assertEqual(current, configured_migration_heads(url)) self.assertEqual(current, configured_migration_heads(url))
self.assertEqual(result.current_revision, ",".join(sorted(current))) self.assertEqual(result.current_revision, ",".join(sorted(current)))
self.assertIn("calendar_outbox_operations", tables)
self.assertIn("calendar_sync_credentials", tables) self.assertIn("calendar_sync_credentials", tables)
self.assertIn("campaign_recipient_import_mapping_profiles", tables) self.assertIn("campaign_recipient_import_mapping_profiles", tables)
self.assertIn("file_connector_credentials", tables) self.assertIn("file_connector_credentials", tables)
@@ -216,6 +234,7 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertEqual(result.current_revision, ",".join(sorted(current))) self.assertEqual(result.current_revision, ",".join(sorted(current)))
self.assertIn("0f1e2d3c4b5a", current) self.assertIn("0f1e2d3c4b5a", current)
self.assertIn("audit_outbox_events", tables) self.assertIn("audit_outbox_events", tables)
self.assertIn("calendar_outbox_operations", tables)
self.assertIn("file_connector_profiles", tables) self.assertIn("file_connector_profiles", tables)
self.assertIn("core_scopes", tables) self.assertIn("core_scopes", tables)
finally: finally:

View File

@@ -396,8 +396,11 @@ class IdentityOrganizationContractTests(unittest.TestCase):
from govoplan_organizations.backend.directory import SqlOrganizationDirectory from govoplan_organizations.backend.directory import SqlOrganizationDirectory
identity_directory = SqlIdentityDirectory() identity_directory = SqlIdentityDirectory()
idm_directory = SqlIdmDirectory()
organization_directory = SqlOrganizationDirectory() organization_directory = SqlOrganizationDirectory()
idm_directory = SqlIdmDirectory(
identities=identity_directory,
organizations=organization_directory,
)
self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr] self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr]
self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr] self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr]

View File

@@ -12,11 +12,18 @@ from govoplan_access.backend.db.models import Account, User
from govoplan_core.auth import ApiPrincipal, get_api_principal from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.configuration_control import configuration_control_snapshot, create_configuration_change_request from govoplan_core.core.configuration_control import configuration_control_snapshot, create_configuration_change_request
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_identity.backend.directory import SqlIdentityDirectory
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
from govoplan_idm.backend.api.v1.routes import router as idm_router from govoplan_idm.backend.api.v1.routes import router as idm_router
from govoplan_idm.backend.manifest import get_manifest from govoplan_idm.backend.manifest import get_manifest
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
from govoplan_organizations.backend.db.models import OrganizationFunction, OrganizationUnit from govoplan_organizations.backend.db.models import OrganizationFunction, OrganizationUnit
from tests.db_isolation import temporary_database from tests.db_isolation import temporary_database
@@ -50,7 +57,37 @@ class IdmApiTests(unittest.TestCase):
) )
def _app(self, scopes: set[str]) -> FastAPI: def _app(self, scopes: set[str]) -> FastAPI:
identity_directory = SqlIdentityDirectory()
organization_directory = SqlOrganizationDirectory()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="identity",
name="Identity",
version="test",
capability_factories={
CAPABILITY_IDENTITY_DIRECTORY: lambda context: identity_directory,
CAPABILITY_IDENTITY_SEARCH: lambda context: identity_directory,
},
)
)
registry.register(
ModuleManifest(
id="organizations",
name="Organizations",
version="test",
capability_factories={
CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory,
},
)
)
context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context)
configure_runtime(context)
self.addCleanup(clear_runtime)
app = FastAPI() app = FastAPI()
app.state.govoplan_registry = registry
app.include_router(idm_router, prefix="/api/v1") app.include_router(idm_router, prefix="/api/v1")
app.dependency_overrides[get_api_principal] = lambda: self._principal(scopes) app.dependency_overrides[get_api_principal] = lambda: self._principal(scopes)
return app return app

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef
class PollContractTests(unittest.TestCase):
def test_response_ref_remains_backwards_compatible_and_can_carry_answers(self) -> None:
submitted_at = datetime(2026, 7, 20, tzinfo=timezone.utc)
legacy = PollResponseRef(None, submitted_at)
detailed = PollResponseRef(
invitation_id="invitation-1",
submitted_at=submitted_at,
respondent_id="person-1",
answers=(PollAnswerRef(option_id="option-1", option_key="slot-1", value="available"),),
)
self.assertEqual(legacy.answers, ())
self.assertEqual(detailed.answers[0].value, "available")
def test_option_update_command_is_a_complete_immutable_snapshot(self) -> None:
command = PollOptionUpdateCommand(
label="Tuesday 10:00",
value={"slot_id": "slot-1"},
metadata={"source": "scheduling"},
)
self.assertEqual(command.label, "Tuesday 10:00")
self.assertEqual(command.value, {"slot_id": "slot-1"})
if __name__ == "__main__":
unittest.main()

View File

@@ -1,187 +0,0 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "release-doctor.py"
def load_doctor_module():
spec = importlib.util.spec_from_file_location("release_doctor", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class ReleaseDoctorTests(unittest.TestCase):
def test_release_repo_names_include_catalog_and_migration_baseline_owners(self) -> None:
doctor = load_doctor_module()
names = set(doctor.release_repo_names(ROOT.parent))
self.assertIn("govoplan-core", names)
self.assertIn("govoplan-files", names)
self.assertIn("govoplan-idm", names)
def test_repo_findings_detect_dirty_repositories(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
pyproject_version="0.1.7",
local_tag_exists=True,
)
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
self.assertEqual("blocker", findings[0].severity)
self.assertEqual("repo-dirty", findings[0].check_id)
self.assertIn("git status --short", [item.command for item in findings[0].commands])
def test_dubious_ownership_errors_suggest_safe_directory_command(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
safe_directory_required=True,
safe_directory_command="git config --global --add safe.directory /tmp/govoplan-files",
errors=("fatal: detected dubious ownership in repository",),
)
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
self.assertEqual("repo-safe-directory", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
self.assertTrue(findings[0].commands[0].mutating)
self.assertIn("safe.directory", findings[0].commands[0].command)
def test_dubious_ownership_detection_matches_git_error(self) -> None:
doctor = load_doctor_module()
self.assertTrue(
doctor.is_dubious_ownership_error(
"fatal: detected dubious ownership in repository at '/workspace/repo'\n"
"git config --global --add safe.directory /workspace/repo"
)
)
def test_release_migration_strict_errors_suggest_recording_baseline(self) -> None:
doctor = load_doctor_module()
findings = doctor.migration_findings(
{
"release": {
"ok": True,
"graph_errors": [],
"strict_errors": ["current migration heads are not recorded"],
},
"dev": {"ok": True, "graph_errors": [], "strict_errors": []},
},
target_version="0.2.0",
)
self.assertEqual("migration-release-baseline", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
commands = [item.command for item in findings[0].commands]
self.assertTrue(any("--record-release 0.2.0" in item for item in commands))
def test_catalog_findings_detect_missing_signatures(self) -> None:
doctor = load_doctor_module()
findings = doctor.catalog_findings(
{
"catalog_exists": True,
"catalog_path": "/tmp/stable.json",
"core_release_version": "0.1.7",
"signature_count": 0,
"keyring_exists": True,
"key_count": 1,
},
target_version="0.1.7",
online=False,
)
self.assertEqual("catalog-signatures", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
def test_suggested_commands_are_deduplicated(self) -> None:
doctor = load_doctor_module()
suggested = doctor.SuggestedCommand(title="Status", command="git status --short", cwd="/tmp/repo")
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(),
findings=(
doctor.Finding("a", "blocker", "A", "", (suggested,)),
doctor.Finding("b", "warning", "B", "", (suggested,)),
),
)
self.assertEqual((suggested,), doctor.collect_suggested_commands(report))
def test_default_report_is_concise_and_keeps_details_out(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
)
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(repo,),
findings=(doctor.Finding("repo-dirty", "blocker", "govoplan-files has uncommitted changes", "M pyproject.toml"),),
)
concise = doctor.render_text_report(report, detailed=False, include_commands=False)
detailed = doctor.render_text_report(report, detailed=True, include_commands=False)
self.assertIn("dirty=1", concise)
self.assertNotIn("M pyproject.toml", concise)
self.assertIn("M pyproject.toml", detailed)
def test_interactive_actions_offer_dirty_bulk_commit_push(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
)
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(repo,),
findings=(),
)
actions = dict(doctor.interactive_actions(report))
self.assertIn("commit-push-dirty", actions)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,190 +0,0 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "release-migration-audit.py"
def load_audit_module():
spec = importlib.util.spec_from_file_location("release_migration_audit", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class ReleaseMigrationAuditTests(unittest.TestCase):
def test_typed_alembic_variables_are_parsed(self) -> None:
audit = load_audit_module()
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
path = Path(directory) / "1234_example.py"
path.write_text(
"""
from __future__ import annotations
from typing import Sequence, Union
revision: str = "1234"
down_revision: Union[str, None] = "base"
depends_on: Union[str, None] = "core"
branch_labels: Union[str, Sequence[str], None] = None
""",
encoding="utf-8",
)
migration = audit.parse_migration_file("govoplan-core", path)
self.assertIsNotNone(migration)
self.assertEqual(migration.revision, "1234")
self.assertEqual(migration.down_revisions, ("base",))
self.assertEqual(migration.depends_on, ("core",))
self.assertEqual(migration.branch_labels, ())
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={
"version": 1,
"releases": [
{
"release": "0.1.0",
"heads": [{"owner": "govoplan-core", "revision": "head"}],
}
],
},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["current_heads"], ["head"])
self.assertEqual(report["graph_errors"], [])
self.assertEqual(report["strict_errors"], [])
def test_owner_heads_are_accepted_for_subset_installs(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={
"version": 1,
"releases": [
{
"release": "0.1.0",
"heads": [{"owner": "govoplan-files", "revision": "files-head"}],
"owner_heads": [{"owner": "govoplan-core", "revisions": ["core-head"]}],
}
],
},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["current_heads"], ["core-head"])
self.assertEqual(report["strict_errors"], [])
def test_records_current_release_baseline(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
audit.Migration("govoplan-files", Path("files_head.py"), "files-head", ("core-head",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
baseline = audit.record_release_baseline(
{"version": 1, "releases": []},
report,
release="0.2.0",
replace=False,
)
release = baseline["releases"][0]
self.assertEqual(release["release"], "0.2.0")
self.assertEqual(release["squash_policy"], "reviewed-manual")
self.assertEqual(release["heads"], [{"owner": "govoplan-files", "revision": "files-head"}])
self.assertIn({"owner": "govoplan-core", "revisions": ["core-head"]}, release["owner_heads"])
def test_missing_down_revision_is_a_graph_error(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("head.py"), "head", ("missing",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertIn("references missing down_revision", report["graph_errors"][0])
def test_depends_on_participates_in_graph_validation_and_heads(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("core.py"), "core", (), (), ()),
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("core",), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["graph_errors"], [])
self.assertEqual(report["current_heads"], ["files"])
def test_missing_depends_on_is_a_graph_error(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("missing",), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertIn("references missing depends_on", report["graph_errors"][0])
def test_dev_track_does_not_emit_release_baseline_warnings(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
track="dev",
)
self.assertEqual(report["track"], "dev")
self.assertEqual(report["strict_errors"], [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.policy import (
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
PolicySourceStep,
SchedulingParticipantPrivacyDecision,
SchedulingParticipantPrivacyPolicy,
SchedulingParticipantPrivacyRequest,
scheduling_participant_privacy_policy,
)
from govoplan_core.core.registry import PlatformRegistry
class _PrivacyPolicy:
def resolve_scheduling_participant_visibility(
self,
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
del session
return SchedulingParticipantPrivacyDecision(
effective_visibility="aggregates_only",
reason=f"Participant roster is restricted for {request.scheduling_request_id}.",
details={"requested_visibility": request.requested_visibility},
)
class SchedulingPrivacyContractTests(unittest.TestCase):
def test_capability_name_and_request_context_are_stable(self) -> None:
self.assertEqual(
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
"policy.schedulingParticipantPrivacy",
)
request = SchedulingParticipantPrivacyRequest(
tenant_id="tenant-1",
scheduling_request_id="request-1",
participant_id="participant-1",
actor_user_id="user-1",
requested_visibility="names_and_statuses",
context={"request_status": "open"},
)
self.assertEqual(request.requested_visibility, "names_and_statuses")
self.assertEqual(request.context["request_status"], "open")
def test_decision_serializes_policy_provenance(self) -> None:
decision = SchedulingParticipantPrivacyDecision(
effective_visibility="aggregates_only",
reason="Tenant privacy policy restricts the participant roster.",
source_path=(
PolicySourceStep(
scope_type="tenant",
scope_id="tenant-1",
label="Tenant",
applied_fields=("scheduling_participant_visibility",),
),
),
details={"requested_visibility": "names_and_statuses"},
)
payload = decision.to_dict()
self.assertEqual(payload["effective_visibility"], "aggregates_only")
self.assertEqual(payload["source_path"][0]["path"], "tenant:tenant-1")
self.assertEqual(payload["details"]["requested_visibility"], "names_and_statuses")
def test_optional_provider_resolves_through_platform_registry(self) -> None:
provider = _PrivacyPolicy()
self.assertIsInstance(provider, SchedulingParticipantPrivacyPolicy)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="policy_test",
name="Policy test",
version="test",
capability_factories={
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: lambda context: provider,
},
),
)
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
self.assertIs(scheduling_participant_privacy_policy(registry), provider)
def test_optional_provider_lookup_does_not_choose_a_fallback(self) -> None:
self.assertIsNone(scheduling_participant_privacy_policy(None))
self.assertIsNone(scheduling_participant_privacy_policy(PlatformRegistry()))
if __name__ == "__main__":
unittest.main()

View File

@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",

View File

@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8", "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -22,6 +22,7 @@
"preview": "vite preview --host 127.0.0.1 --port 4173", "preview": "vite preview --host 127.0.0.1 --port 4173",
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs", "audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs", "test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js", "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs", "test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js" "test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",

View File

@@ -2,7 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth"; import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform"; import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client"; import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types"; import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import AppShell from "./layout/AppShell"; import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage"; import PublicLandingPage from "./features/auth/PublicLandingPage";
@@ -32,6 +32,7 @@ export default function App() {
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null); const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]); const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null }); const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
const [backendReachable, setBackendReachable] = useState(true);
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null); const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
const [reloginMessage, setReloginMessage] = useState(""); const [reloginMessage, setReloginMessage] = useState("");
@@ -87,6 +88,7 @@ export default function App() {
try { try {
const response = await fetchPlatformStatus(settings); const response = await fetchPlatformStatus(settings);
if (cancelled) return; if (cancelled) return;
setBackendReachable(true);
setMaintenanceMode(response.maintenance_mode); setMaintenanceMode(response.maintenance_mode);
if (response.i18n) { if (response.i18n) {
setSystemLanguages({ setSystemLanguages({
@@ -100,7 +102,10 @@ export default function App() {
}); });
} }
} catch { } catch {
if (!cancelled) setMaintenanceMode({ enabled: false, message: null }); if (!cancelled) {
setBackendReachable(false);
setMaintenanceMode({ enabled: false, message: null });
}
} }
} }
@@ -121,9 +126,13 @@ export default function App() {
async function bootstrapAuth() { async function bootstrapAuth() {
try { try {
const shellAuth = await fetchShellAuth(settings); const shellAuth = await fetchShellAuth(settings);
if (!cancelled) setAuth(normalizeAuthInfo(shellAuth));
} catch {
if (!cancelled) { if (!cancelled) {
setBackendReachable(true);
setAuth(normalizeAuthInfo(shellAuth));
}
} catch (error) {
if (!cancelled) {
setBackendReachable(isApiError(error));
const cleared = { ...settings, accessToken: "" }; const cleared = { ...settings, accessToken: "" };
setSettings(cleared); setSettings(cleared);
saveApiSettings(cleared); saveApiSettings(cleared);
@@ -243,6 +252,7 @@ export default function App() {
try { try {
const sessionInfo = await fetchSession(settings); const sessionInfo = await fetchSession(settings);
if (cancelled) return; if (cancelled) return;
setBackendReachable(true);
const shellRefreshDue = now - lastShellRefreshAt >= 60_000; const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) { if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
const shellAuth = await fetchShellAuth(settings); const shellAuth = await fetchShellAuth(settings);
@@ -252,7 +262,10 @@ export default function App() {
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth)) ? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
: normalizeAuthInfo(shellAuth)); : normalizeAuthInfo(shellAuth));
} }
} catch { } catch (error) {
if (!cancelled && !isApiError(error)) {
setBackendReachable(false);
}
// A background refresh must not log the user out on a transient network error. // A background refresh must not log the user out on a transient network error.
@@ -273,7 +286,7 @@ export default function App() {
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}> <PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={webModules}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}> <AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<div className="public-landing"> <div className="public-landing">
<section className="public-card"> <section className="public-card">
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div> <div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
@@ -293,7 +306,7 @@ export default function App() {
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}> <PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={webModules}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}> <AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} /> <PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
</AppShell> </AppShell>
</UnsavedChangesProvider> </UnsavedChangesProvider>
@@ -324,7 +337,7 @@ export default function App() {
moduleTranslations={moduleTranslations}> moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={webModules}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode}> <AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}> <Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
<Routes key={(auth.active_tenant ?? auth.tenant).id}> <Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} /> <Route path="/" element={<Navigate to={defaultRoute} replace />} />

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Archive, LockKeyhole, Paperclip } from "lucide-react"; import { Archive, Link2, LockKeyhole, Paperclip, X } from "lucide-react";
import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext"; import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
import SegmentedControl from "./SegmentedControl"; import SegmentedControl from "./SegmentedControl";
@@ -14,6 +14,7 @@ export type MessageDisplayAttachment = {
contentType?: string | null; contentType?: string | null;
sizeBytes?: number | null; sizeBytes?: number | null;
detail?: string | null; detail?: string | null;
linkedToCampaign?: boolean | null;
archiveGroup?: string | null; archiveGroup?: string | null;
archiveLabel?: string | null; archiveLabel?: string | null;
protected?: boolean | null; protected?: boolean | null;
@@ -159,9 +160,15 @@ function AttachmentRow({ attachment, index }: {attachment: MessageDisplayAttachm
const contentType = formatContentType(attachment.contentType); const contentType = formatContentType(attachment.contentType);
const size = formatBytes(attachment.sizeBytes); const size = formatBytes(attachment.sizeBytes);
const hasMeta = Boolean(contentType || size); const hasMeta = Boolean(contentType || size);
const LinkIcon = attachment.linkedToCampaign === true ? Link2 : attachment.linkedToCampaign === false ? X : Paperclip;
const linkStateClass = attachment.linkedToCampaign === true ?
" is-linked" :
attachment.linkedToCampaign === false ?
" is-unlinked" :
"";
return ( return (
<div className="message-display-attachment-row"> <div className={`message-display-attachment-row${linkStateClass}`}>
<Paperclip size={14} aria-hidden="true" /> <LinkIcon size={14} aria-hidden="true" />
<span> <span>
<strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong> <strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong>
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>} {attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}

View File

@@ -5,15 +5,21 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
type ToggleSwitchProps = { type ToggleSwitchProps = {
label: ReactNode; label: ReactNode;
activeLabel?: ReactNode;
inactiveLabel?: ReactNode;
checked: boolean; checked: boolean;
onChange?: (checked: boolean) => void; onChange?: (checked: boolean) => void;
disabled?: boolean; disabled?: boolean;
help?: ReactNode; help?: ReactNode;
}; };
export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) { export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
const renderedLabel = typeof label === "string" ? translateText(label) : label; const renderedLabel = typeof label === "string" ? translateText(label) : label;
const renderedInactiveLabel = typeof inactiveLabel === "string" ? translateText(inactiveLabel) : inactiveLabel;
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
return ( return (
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}> <label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
<input <input
@@ -21,12 +27,25 @@ export default function ToggleSwitch({ label, checked, onChange, disabled = fals
type="checkbox" type="checkbox"
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
aria-label={inputLabel}
onChange={(event) => onChange?.(event.target.checked)} onChange={(event) => onChange?.(event.target.checked)}
/> />
{hasStateLabels && inactiveLabel !== undefined &&
<span className="toggle-switch-state-label is-inactive" data-selected={!checked || undefined}>
{renderedInactiveLabel}
</span>
}
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span> <span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
{hasStateLabels && activeLabel !== undefined &&
<span className="toggle-switch-state-label is-active" data-selected={checked || undefined}>
{renderedActiveLabel}
</span>
}
{!hasStateLabels &&
<span className="toggle-switch-copy"> <span className="toggle-switch-copy">
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel> <FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
</span> </span>
}
</label> </label>
); );
} }

View File

@@ -1,10 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Button from "../Button"; import Button from "../Button";
import { CredentialFields } from "../CredentialPanel"; import { CredentialFields } from "../CredentialPanel";
import DismissibleAlert from "../DismissibleAlert"; import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField"; import FormField from "../FormField";
import SegmentedControl from "../SegmentedControl"; import SegmentedControl from "../SegmentedControl";
import ToggleSwitch from "../ToggleSwitch";
export type MailServerSecurity = "plain" | "tls" | "starttls" | string; export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
@@ -48,7 +47,7 @@ export type MailServerFolderLookupResult = {
details?: Record<string, unknown> | null; details?: Record<string, unknown> | null;
}; };
export type MailServerSettingsSection = "smtp" | "imap" | "advanced"; export type MailServerSettingsSection = "smtp" | "imap";
export type MailServerSettingsMode = "all" | "server" | "credentials"; export type MailServerSettingsMode = "all" | "server" | "credentials";
export type MailServerSettingsPanelProps = { export type MailServerSettingsPanelProps = {
@@ -72,30 +71,11 @@ export type MailServerSettingsPanelProps = {
imapActionDisabled?: boolean; imapActionDisabled?: boolean;
smtpTestLabel?: string; smtpTestLabel?: string;
imapTestLabel?: string; imapTestLabel?: string;
folderLookupLabel?: string;
busyAction?: "smtp" | "imap" | "folders" | string | null; busyAction?: "smtp" | "imap" | "folders" | string | null;
onTestSmtp?: () => void; onTestSmtp?: () => void;
onTestImap?: () => void; onTestImap?: () => void;
onLookupFolders?: () => void;
smtpTestResult?: MailServerConnectionTestResult | null; smtpTestResult?: MailServerConnectionTestResult | null;
imapTestResult?: MailServerConnectionTestResult | null; imapTestResult?: MailServerConnectionTestResult | null;
folderLookupResult?: MailServerFolderLookupResult | null;
onUseDetectedFolder?: () => void;
useDetectedFolderDisabled?: boolean;
mockToggle?: {
label?: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
};
append?: {
enabled: boolean;
folder: string;
disabled?: boolean;
folderDisabled?: boolean;
onEnabledChange: (enabled: boolean) => void;
onFolderChange: (folder: string) => void;
};
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
floatingResults?: boolean; floatingResults?: boolean;
@@ -202,6 +182,19 @@ export function hasMailImapSettings(values: Array<string | number | null | undef
return values.some((value) => String(value ?? "").trim() !== ""); return values.some((value) => String(value ?? "").trim() !== "");
} }
export function resolveMailServerSettingsActiveSection(
activeSection: MailServerSettingsSection,
initialSection: MailServerSettingsSection,
previousInitialSection: MailServerSettingsSection,
visibleSectionIds: readonly MailServerSettingsSection[])
: MailServerSettingsSection {
const fallbackSection = visibleSectionIds[0] ?? "smtp";
const initialSectionVisible = visibleSectionIds.includes(initialSection);
if (previousInitialSection !== initialSection && initialSectionVisible) return initialSection;
if (!visibleSectionIds.includes(activeSection)) return initialSectionVisible ? initialSection : fallbackSection;
return activeSection;
}
export default function MailServerSettingsPanel({ export default function MailServerSettingsPanel({
smtp, smtp,
imap, imap,
@@ -223,18 +216,11 @@ export default function MailServerSettingsPanel({
imapActionDisabled = imapServerDisabled, imapActionDisabled = imapServerDisabled,
smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981", smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981",
imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c", imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c",
folderLookupLabel = "i18n:govoplan-core.folders.c603ab65",
busyAction = null, busyAction = null,
onTestSmtp, onTestSmtp,
onTestImap, onTestImap,
onLookupFolders,
smtpTestResult = null, smtpTestResult = null,
imapTestResult = null, imapTestResult = null,
folderLookupResult = null,
onUseDetectedFolder,
useDetectedFolderDisabled = false,
mockToggle,
append,
disabled = false, disabled = false,
className = "", className = "",
floatingResults = false, floatingResults = false,
@@ -254,35 +240,31 @@ export default function MailServerSettingsPanel({
const imapSecurity = stringValue(imap.security, "tls"); const imapSecurity = stringValue(imap.security, "tls");
const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity))); const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity)));
const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity))); const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity)));
const appendTargetFolder = append ? append.folder : stringValue(imap.sent_folder, "auto");
const appendTargetDisabled = append ? disabled || append.folderDisabled : imapFieldsDisabled;
const canLookupAppendFolders = Boolean(onLookupFolders) && !appendTargetDisabled;
const appendTargetHelp = append ?
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" :
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e";
const allSections: {id: MailServerSettingsSection;label: string;}[] = [ const allSections: {id: MailServerSettingsSection;label: string;}[] = [
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" }, { id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" }, { id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" }];
{ id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }];
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id)); const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
const sections = allSections.filter((section) => visibleSectionSet.has(section.id)); const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
const fallbackSection = sections[0]?.id ?? "smtp"; const visibleSectionIds = sections.map((section) => section.id);
const resolvedInitialSection = sections.some((section) => section.id === initialSection) ? initialSection : fallbackSection; const fallbackSection = visibleSectionIds[0] ?? "smtp";
const sectionKey = sections.map((section) => section.id).join("|"); const resolvedInitialSection = visibleSectionIds.includes(initialSection) ? initialSection : fallbackSection;
const sectionKey = visibleSectionIds.join("|");
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection); const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
const previousInitialSectionRef = useRef(initialSection);
const showSectionSwitcher = sections.length > 1; const showSectionSwitcher = sections.length > 1;
const showServerFields = mode !== "credentials"; const showServerFields = mode !== "credentials";
const showCredentialFields = mode !== "server"; const showCredentialFields = mode !== "server";
useEffect(() => { useEffect(() => {
if (!sections.some((section) => section.id === activeSection)) { const nextSection = resolveMailServerSettingsActiveSection(
setActiveSection(resolvedInitialSection); activeSection,
return; initialSection,
} previousInitialSectionRef.current,
if (initialSection !== activeSection && sections.some((section) => section.id === initialSection)) { visibleSectionIds
setActiveSection(initialSection); );
} previousInitialSectionRef.current = initialSection;
}, [activeSection, initialSection, resolvedInitialSection, sectionKey]); if (nextSection !== activeSection) setActiveSection(nextSection);
}, [activeSection, initialSection, sectionKey]);
function patchSmtpSecurity(security: MailServerSecurity) { function patchSmtpSecurity(security: MailServerSecurity) {
@@ -324,10 +306,10 @@ export default function MailServerSettingsPanel({
<div className="form-grid compact responsive-form-grid mail-server-form-grid"> <div className="form-grid compact responsive-form-grid mail-server-form-grid">
{showServerFields && {showServerFields &&
<> <>
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div> <FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField> <FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField> <FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
</> </>
} }
{showCredentialFields && {showCredentialFields &&
@@ -336,9 +318,7 @@ export default function MailServerSettingsPanel({
onChange={patchSmtpCredentials} onChange={patchSmtpCredentials}
disabled={smtpCredentialFieldsDisabled} disabled={smtpCredentialFieldsDisabled}
savedPassword={smtpPasswordSaved} savedPassword={smtpPasswordSaved}
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder} savedPasswordPlaceholder={smtpSavedPasswordPlaceholder} />
heading="i18n:govoplan-core.credentials.dd097a22"
headingClassName="mail-server-field-heading" />
} }
</div> </div>
{onTestSmtp && {onTestSmtp &&
@@ -355,10 +335,10 @@ export default function MailServerSettingsPanel({
<div className="form-grid compact responsive-form-grid mail-server-form-grid"> <div className="form-grid compact responsive-form-grid mail-server-form-grid">
{showServerFields && {showServerFields &&
<> <>
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div> <FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField> <FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField> <FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
</> </>
} }
{showCredentialFields && {showCredentialFields &&
@@ -367,9 +347,7 @@ export default function MailServerSettingsPanel({
onChange={patchImapCredentials} onChange={patchImapCredentials}
disabled={imapCredentialFieldsDisabled} disabled={imapCredentialFieldsDisabled}
savedPassword={imapPasswordSaved} savedPassword={imapPasswordSaved}
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
heading="i18n:govoplan-core.credentials.dd097a22"
headingClassName="mail-server-field-heading" />
} }
</div> </div>
{onTestImap && {onTestImap &&
@@ -381,41 +359,6 @@ export default function MailServerSettingsPanel({
</section> </section>
} }
{activeSection === "advanced" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.advanced_mail_settings.1f69439a">
<div className="form-grid compact responsive-form-grid mail-server-form-grid mail-server-advanced-grid">
{mockToggle &&
<div className="mail-server-field-span mail-server-toggle-row">
<ToggleSwitch
label={mockToggle.label ?? "i18n:govoplan-core.mock_server_settings.9361d5c5"}
checked={mockToggle.checked}
disabled={disabled || mockToggle.disabled}
onChange={mockToggle.onChange} />
</div>
}
<FormField label="i18n:govoplan-core.smtp_timeout_seconds.ac87c8d2"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.imap_timeout_seconds.489af2a4"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
{append &&
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
<ToggleSwitch label="i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
</div>
}
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help={appendTargetHelp}>
<div className="field-with-action mail-server-folder-field">
<input
value={appendTargetFolder}
disabled={appendTargetDisabled}
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
placeholder="i18n:govoplan-core.auto.0d612c12" />
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : folderLookupLabel}</Button>}
</div>
</FormField>
{canLookupAppendFolders && <MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled || appendTargetDisabled} onUseDetected={onUseDetectedFolder} />}
</div>
</section>
}
</div> </div>
</div>); </div>);
@@ -437,20 +380,18 @@ export function MailServerActionResult({ result, floating = false }: {result: Ma
export function MailServerFolderLookupResultView({ export function MailServerFolderLookupResultView({
result, result,
disabled = false, disabled = false,
onUseDetected onUseDetected,
compact = true,
floatingFailures = false
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;compact?: boolean;floatingFailures?: boolean;}) {
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;}) {
if (!result) return null; if (!result) return null;
if (!result.ok) { if (!result.ok) {
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>; return <DismissibleAlert tone="warning" compact={compact} resetKey={result.message} floating={floatingFailures}>{result.message}</DismissibleAlert>;
} }
const folders = result.folders ?? []; const folders = result.folders ?? [];
return ( return (
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}> <DismissibleAlert tone="success" compact={compact} resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
<p>{result.message}</p> <p>{result.message}</p>
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p> <p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p>
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>} {result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>}

View File

@@ -860,6 +860,7 @@ export function DataGridRowActions({
return ( return (
<div className="data-grid-row-actions"> <div className="data-grid-row-actions">
<Button <Button
type="button"
variant="primary" variant="primary"
className="data-grid-row-action is-add" className="data-grid-row-action is-add"
aria-label={translatedAddLabel} aria-label={translatedAddLabel}
@@ -870,6 +871,7 @@ export function DataGridRowActions({
<Plus size={16} aria-hidden="true" /> <Plus size={16} aria-hidden="true" />
</Button> </Button>
<Button <Button
type="button"
variant="secondary" variant="secondary"
className="data-grid-row-action is-reorder" className="data-grid-row-action is-reorder"
aria-label={translatedMoveUpLabel} aria-label={translatedMoveUpLabel}
@@ -880,6 +882,7 @@ export function DataGridRowActions({
<ArrowUp size={16} aria-hidden="true" /> <ArrowUp size={16} aria-hidden="true" />
</Button> </Button>
<Button <Button
type="button"
variant="secondary" variant="secondary"
className="data-grid-row-action is-reorder" className="data-grid-row-action is-reorder"
aria-label={translatedMoveDownLabel} aria-label={translatedMoveDownLabel}
@@ -890,6 +893,7 @@ export function DataGridRowActions({
<ArrowDown size={16} aria-hidden="true" /> <ArrowDown size={16} aria-hidden="true" />
</Button> </Button>
<Button <Button
type="button"
variant="danger" variant="danger"
className="data-grid-row-action is-remove" className="data-grid-row-action is-remove"
aria-label={translatedRemoveLabel} aria-label={translatedRemoveLabel}
@@ -917,6 +921,7 @@ export function DataGridEmptyAction({
return ( return (
<div className="data-grid-row-actions data-grid-empty-row-actions"> <div className="data-grid-row-actions data-grid-empty-row-actions">
<Button <Button
type="button"
variant="primary" variant="primary"
className="data-grid-row-action is-add" className="data-grid-row-action is-add"
aria-label={translatedLabel} aria-label={translatedLabel}

View File

@@ -13,6 +13,7 @@ type Props = {
publicMode?: boolean; publicMode?: boolean;
navItems?: PlatformNavItem[]; navItems?: PlatformNavItem[];
maintenanceMode?: { enabled: boolean; message?: string | null }; maintenanceMode?: { enabled: boolean; message?: string | null };
backendReachable?: boolean;
}; };
export default function AppShell({ export default function AppShell({
@@ -23,7 +24,8 @@ export default function AppShell({
onAuthChange, onAuthChange,
publicMode = false, publicMode = false,
navItems = [], navItems = [],
maintenanceMode maintenanceMode,
backendReachable = true
}: Props) { }: Props) {
const location = useLocation(); const location = useLocation();
@@ -32,7 +34,7 @@ export default function AppShell({
<div className="app-shell public-shell"> <div className="app-shell public-shell">
<IconRail compact auth={auth} navItems={navItems} /> <IconRail compact auth={auth} navItems={navItems} />
<div className="app-main public-main"> <div className="app-main public-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} /> <Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
<main className="public-content">{children}</main> <main className="public-content">{children}</main>
</div> </div>
</div> </div>
@@ -43,7 +45,7 @@ export default function AppShell({
<div className="app-shell"> <div className="app-shell">
<IconRail auth={auth} navItems={navItems} /> <IconRail auth={auth} navItems={navItems} />
<div className="app-main"> <div className="app-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} /> <Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
<BreadcrumbBar pathname={location.pathname} /> <BreadcrumbBar pathname={location.pathname} />
<main className="app-content">{children}</main> <main className="app-content">{children}</main>
</div> </div>

View File

@@ -23,9 +23,10 @@ type Props = {
onSettingsChange: (settings: ApiSettings) => void; onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void; onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
maintenanceMode?: {enabled: boolean;message?: string | null;}; maintenanceMode?: {enabled: boolean;message?: string | null;};
backendReachable?: boolean;
}; };
export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode }: Props) { export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode, backendReachable = true }: Props) {
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const { requestNavigation } = useUnsavedChanges(); const { requestNavigation } = useUnsavedChanges();
const [accountOpen, setAccountOpen] = useState(false); const [accountOpen, setAccountOpen] = useState(false);
@@ -161,7 +162,15 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
return ( return (
<header className="titlebar"> <header className="titlebar">
{maintenanceMode?.enabled && {!backendReachable ?
<div
className="backend-offline-topbar-alert"
role="status"
aria-live="polite"
title="System not reachable / offline!">
System not reachable / offline!
</div> :
maintenanceMode?.enabled &&
<button <button
type="button" type="button"
className="maintenance-topbar-link" className="maintenance-topbar-link"

View File

@@ -1564,20 +1564,31 @@
box-shadow: var(--shadow-thumb); box-shadow: var(--shadow-thumb);
transition: transform .16s ease; transition: transform .16s ease;
} }
.toggle-switch-input:checked + .toggle-switch-track { .toggle-switch-input:checked ~ .toggle-switch-track {
border-color: var(--primary); border-color: var(--primary);
background: var(--primary); background: var(--primary);
} }
.toggle-switch-input:checked + .toggle-switch-track .toggle-switch-thumb { .toggle-switch-input:checked ~ .toggle-switch-track .toggle-switch-thumb {
transform: translateX(18px); transform: translateX(18px);
} }
.toggle-switch-input:focus-visible + .toggle-switch-track { .toggle-switch-input:focus-visible ~ .toggle-switch-track {
box-shadow: var(--primary-focus-ring-soft), var(--shadow-control-inset-strong); box-shadow: var(--primary-focus-ring-soft), var(--shadow-control-inset-strong);
} }
.toggle-switch-input:disabled + .toggle-switch-track { .toggle-switch-input:disabled ~ .toggle-switch-track {
filter: grayscale(.15); filter: grayscale(.15);
opacity: .7; opacity: .7;
} }
.toggle-switch-state-label {
color: var(--muted);
font-size: 13px;
font-weight: 650;
line-height: 1;
white-space: nowrap;
}
.toggle-switch-state-label[data-selected="true"] {
color: var(--text-strong);
font-weight: 800;
}
.toggle-switch-copy { .toggle-switch-copy {
display: grid; display: grid;
gap: 2px; gap: 2px;
@@ -1949,6 +1960,21 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
.compact-alert {
padding: 10px 12px;
margin-bottom: 10px;
font-size: 13px;
line-height: 1.4;
}
.compact-alert .alert-message > :first-child {
margin-top: 0;
}
.compact-alert .alert-message > :last-child {
margin-bottom: 0;
}
.alert-floating-stack { .alert-floating-stack {
position: fixed; position: fixed;
top: 131px; top: 131px;
@@ -2489,6 +2515,18 @@
background: var(--surface); background: var(--surface);
} }
.message-display-attachment-row > svg {
margin-top: 1px;
}
.message-display-attachment-row.is-linked > svg {
color: var(--green);
}
.message-display-attachment-row.is-unlinked > svg {
color: var(--red);
}
.message-display-attachment-row > span { .message-display-attachment-row > span {
display: grid; display: grid;
gap: 3px; gap: 3px;
@@ -2580,9 +2618,9 @@
gap: 12px; gap: 12px;
} }
.mail-server-segmented-control { .mail-server-segmented-control {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
justify-self: center; justify-self: center;
width: min(420px, 100%); width: min(320px, 100%);
} }
.mail-server-settings-view { .mail-server-settings-view {
min-width: 0; min-width: 0;
@@ -2610,7 +2648,7 @@
letter-spacing: .01em; letter-spacing: .01em;
} }
.form-grid.compact.mail-server-form-grid { .form-grid.compact.mail-server-form-grid {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.mail-server-form-grid { .mail-server-form-grid {
align-items: start; align-items: start;
@@ -2668,7 +2706,7 @@
.mail-server-settings-grid { grid-template-columns: 1fr; } .mail-server-settings-grid { grid-template-columns: 1fr; }
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.form-grid.compact.mail-server-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .mail-server-segmented-control { width: min(320px, 100%); }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.credential-panel-grid { grid-template-columns: 1fr; } .credential-panel-grid { grid-template-columns: 1fr; }

View File

@@ -36,8 +36,11 @@
.titlebar-notification-button { position: relative; } .titlebar-notification-button { position: relative; }
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); } .titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
.account-pill { color: var(--text); } .account-pill { color: var(--text); }
.maintenance-topbar-link { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); border-radius: 6px; min-height: 32px; padding: 0 14px; font: inherit; font-weight: 800; box-shadow: 0 1px 2px var(--hover-tint); cursor: pointer; z-index: 1; } .maintenance-topbar-link,
.backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border-radius: 6px; min-height: 32px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; font: inherit; font-weight: 800; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; white-space: nowrap; }
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); cursor: pointer; }
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); } .maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--red); color: var(--on-accent); }
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; } .language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; } .language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
.language-menu { min-width: 210px; } .language-menu { min-width: 210px; }

View File

@@ -0,0 +1,32 @@
function assertEqual<T>(actual: T, expected: T, message: string): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
import { renderToStaticMarkup } from "react-dom/server";
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
function noop() {}
function buttonCount(markup: string): number {
return markup.match(/<button\b/g)?.length ?? 0;
}
function nonSubmittingButtonCount(markup: string): number {
return markup.match(/<button\b[^>]*\btype="button"/g)?.length ?? 0;
}
const rowActions = renderToStaticMarkup(
<form>
<DataGridRowActions onAddBelow={noop} onRemove={noop} onMoveUp={noop} onMoveDown={noop} />
</form>
);
assertEqual(buttonCount(rowActions), 4, "row actions render the expected controls");
assertEqual(nonSubmittingButtonCount(rowActions), 4, "row actions do not submit an enclosing form");
const emptyAction = renderToStaticMarkup(
<form>
<DataGridEmptyAction onAdd={noop} />
</form>
);
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");

View File

@@ -16,12 +16,34 @@ import { renderToStaticMarkup } from "react-dom/server";
import CredentialPanel from "../src/components/CredentialPanel"; import CredentialPanel from "../src/components/CredentialPanel";
import PasswordField from "../src/components/PasswordField"; import PasswordField from "../src/components/PasswordField";
import MessageDisplayPanel from "../src/components/MessageDisplayPanel"; import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
import MailServerSettingsPanel from "../src/components/mail/MailServerSettingsPanel"; import MailServerSettingsPanel, { MailServerFolderLookupResultView, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
import EmailAddressInput from "../src/components/email/EmailAddressInput"; import EmailAddressInput from "../src/components/email/EmailAddressInput";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
function noop() {} function noop() {}
const allMailServerSections = ["smtp", "imap"] as const;
assertEqual(
resolveMailServerSettingsActiveSection("imap", "smtp", "smtp", allMailServerSections),
"imap",
"user-selected mail settings section is not reset to initialSection"
);
assertEqual(
resolveMailServerSettingsActiveSection("smtp", "imap", "smtp", allMailServerSections),
"imap",
"changed initialSection is applied when parent intentionally changes it"
);
assertEqual(
resolveMailServerSettingsActiveSection("imap", "smtp", "smtp", ["smtp"] as const),
"smtp",
"active mail settings section falls back when hidden by visibleSections"
);
assertEqual(
resolveMailServerSettingsActiveSection("smtp", "imap", "imap", ["smtp"] as const),
"smtp",
"hidden initialSection falls back to the first visible section"
);
const savedPassword = renderToStaticMarkup( const savedPassword = renderToStaticMarkup(
<PasswordField value="" saved savedPlaceholder="Saved password configured" onValueChange={noop} /> <PasswordField value="" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
); );
@@ -75,20 +97,14 @@ const settingsPanel = renderToStaticMarkup(
imapSavedPasswordPlaceholder="Saved IMAP password" imapSavedPasswordPlaceholder="Saved IMAP password"
onTestSmtp={noop} onTestSmtp={noop}
onTestImap={noop} onTestImap={noop}
onLookupFolders={noop}
folderLookupResult={{
ok: true,
protocol: "imap",
message: "Folders loaded",
detected_sent_folder: "Sent",
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
}}
onUseDetectedFolder={noop}
/> />
); );
assert(settingsPanel.includes("i18n:govoplan-core.test_smtp.e5697981"), "SMTP test action is rendered"); assert(settingsPanel.includes("i18n:govoplan-core.test_smtp.e5697981"), "SMTP test action is rendered");
assert(settingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "server heading is rendered"); assert(settingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "server field is rendered");
assert(settingsPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "credentials heading is rendered"); assert(settingsPanel.includes("i18n:govoplan-core.username.84c29015"), "username field is rendered");
assert(settingsPanel.includes("i18n:govoplan-core.password.8be3c943"), "password field is rendered");
assert(settingsPanel.includes("i18n:govoplan-core.timeout_seconds.0bfc6553"), "timeout field is rendered with SMTP server settings");
assert(!settingsPanel.includes("i18n:govoplan-core.advanced.4d064726"), "advanced section tab is not rendered");
assert(!settingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered"); assert(!settingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered");
assert(settingsPanel.includes('placeholder="Saved SMTP password"'), "SMTP saved credential placeholder is rendered"); assert(settingsPanel.includes('placeholder="Saved SMTP password"'), "SMTP saved credential placeholder is rendered");
@@ -102,77 +118,45 @@ const imapSettingsPanel = renderToStaticMarkup(
imapPasswordSaved imapPasswordSaved
imapSavedPasswordPlaceholder="Saved IMAP password" imapSavedPasswordPlaceholder="Saved IMAP password"
onTestImap={noop} onTestImap={noop}
onLookupFolders={noop}
folderLookupResult={{
ok: true,
protocol: "imap",
message: "Folders loaded",
detected_sent_folder: "Sent",
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
}}
onUseDetectedFolder={noop}
/> />
); );
assert(imapSettingsPanel.includes("i18n:govoplan-core.test_imap.ef1bd79c"), "IMAP test action is rendered"); assert(imapSettingsPanel.includes("i18n:govoplan-core.test_imap.ef1bd79c"), "IMAP test action is rendered");
assert(imapSettingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "IMAP server heading is rendered"); assert(imapSettingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "IMAP server field is rendered");
assert(imapSettingsPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "IMAP credentials heading is rendered"); assert(imapSettingsPanel.includes("i18n:govoplan-core.username.84c29015"), "IMAP username field is rendered");
assert(imapSettingsPanel.includes("i18n:govoplan-core.password.8be3c943"), "IMAP password field is rendered");
assert(imapSettingsPanel.includes("i18n:govoplan-core.timeout_seconds.0bfc6553"), "IMAP timeout field is rendered with IMAP server settings");
assert(!imapSettingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered in IMAP tab"); assert(!imapSettingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered in IMAP tab");
assert(!imapSettingsPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is not rendered in the IMAP tab"); assert(!imapSettingsPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is not rendered in the IMAP tab");
assert(imapSettingsPanel.includes('placeholder="Saved IMAP password"'), "IMAP saved credential placeholder is rendered"); assert(imapSettingsPanel.includes('placeholder="Saved IMAP password"'), "IMAP saved credential placeholder is rendered");
assert(!imapSettingsPanel.includes("Folders loaded"), "folder lookup result is not rendered in the IMAP tab"); assert(!imapSettingsPanel.includes("Folders loaded"), "folder lookup result is not rendered in the IMAP tab");
assert(!imapSettingsPanel.includes("Default sent folder"), "default sent folder field is not rendered"); assert(!imapSettingsPanel.includes("Default sent folder"), "default sent folder field is not rendered");
const advancedSettingsPanel = renderToStaticMarkup( const folderLookupError = renderToStaticMarkup(
<MailServerSettingsPanel <MailServerFolderLookupResultView result={{ ok: false, protocol: "imap", message: "IMAP lookup timed out", folders: [] }} />
initialSection="advanced" );
smtp={{ host: "smtp.example.org", security: "starttls" }} assert(folderLookupError.includes("compact-alert"), "folder lookup errors use the compact shared alert style");
imap={{ host: "imap.example.org", security: "tls", sent_folder: "Sent" }} assert(folderLookupError.includes("warning"), "folder lookup errors are warnings, not page-level danger alerts");
onSmtpChange={noop} assert(folderLookupError.includes("IMAP lookup timed out"), "folder lookup error message is rendered");
onImapChange={noop}
onLookupFolders={noop} const floatingFolderLookupError = renderToStaticMarkup(
folderLookupResult={{ <MailServerFolderLookupResultView result={{ ok: false, protocol: "imap", message: "IMAP lookup timed out", folders: [] }} floatingFailures />
);
assert(floatingFolderLookupError.includes("alert-floating"), "folder lookup errors can be rendered in the floating alert stack");
const folderLookupSuccess = renderToStaticMarkup(
<MailServerFolderLookupResultView
result={{
ok: true, ok: true,
protocol: "imap", protocol: "imap",
message: "Folders loaded", message: "Folders loaded",
detected_sent_folder: "Sent", detected_sent_folder: "Sent",
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }] folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
}} }}
onUseDetectedFolder={noop} onUseDetected={noop}
/> />
); );
assert(advancedSettingsPanel.includes("i18n:govoplan-core.append_target_folder.0aaacc0c"), "append target folder is the advanced IMAP folder field"); assert(folderLookupSuccess.includes("compact-alert"), "folder lookup success uses the compact shared alert style");
assert(advancedSettingsPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is rendered near append target folder"); assert(folderLookupSuccess.includes("Folders loaded"), "folder lookup success message is rendered");
assert(advancedSettingsPanel.includes("Folders loaded"), "folder lookup result is rendered in Advanced");
assert(advancedSettingsPanel.includes("i18n:govoplan-core.use_detected_folder.5ec4965c"), "detected folder action is rendered near append target folder");
assert(advancedSettingsPanel.includes('value="Sent"'), "append target folder uses IMAP sent folder value without append override");
const disabledAppendFolderPanel = renderToStaticMarkup(
<MailServerSettingsPanel
initialSection="advanced"
smtp={{ host: "smtp.example.org", security: "starttls" }}
imap={{ host: "imap.example.org", security: "tls", sent_folder: "Sent" }}
onSmtpChange={noop}
onImapChange={noop}
onLookupFolders={noop}
folderLookupResult={{
ok: true,
protocol: "imap",
message: "Folders loaded",
detected_sent_folder: "Sent",
folders: [{ name: "Sent", flags: ["\\Sent"] }]
}}
onUseDetectedFolder={noop}
append={{
enabled: true,
folder: "Sent",
folderDisabled: true,
onEnabledChange: noop,
onFolderChange: noop
}}
/>
);
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is hidden when append target is disabled");
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.use_detected_folder.5ec4965c"), "detected folder action is hidden when append target is disabled");
const smtpServerOnlyPanel = renderToStaticMarkup( const smtpServerOnlyPanel = renderToStaticMarkup(
<MailServerSettingsPanel <MailServerSettingsPanel
@@ -185,7 +169,7 @@ const smtpServerOnlyPanel = renderToStaticMarkup(
/> />
); );
assert(smtpServerOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused server panel renders server fields"); assert(smtpServerOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused server panel renders server fields");
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "focused server panel hides credential fields"); assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.username.84c29015"), "focused server panel hides credential fields");
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.mail_server_settings_sections.40a163b7"), "single focused section hides section switcher"); assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.mail_server_settings_sections.40a163b7"), "single focused section hides section switcher");
assert(!smtpServerOnlyPanel.includes("imap.example.org"), "focused SMTP server panel does not render IMAP fields"); assert(!smtpServerOnlyPanel.includes("imap.example.org"), "focused SMTP server panel does not render IMAP fields");
@@ -202,7 +186,7 @@ const imapCredentialsOnlyPanel = renderToStaticMarkup(
imapSavedPasswordPlaceholder="Saved IMAP password" imapSavedPasswordPlaceholder="Saved IMAP password"
/> />
); );
assert(imapCredentialsOnlyPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "focused credentials panel renders credential fields"); assert(imapCredentialsOnlyPanel.includes("i18n:govoplan-core.username.84c29015"), "focused credentials panel renders credential fields");
assert(!imapCredentialsOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused credentials panel hides server fields"); assert(!imapCredentialsOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused credentials panel hides server fields");
assert(imapCredentialsOnlyPanel.includes('placeholder="Saved IMAP password"'), "focused credentials panel preserves saved password UX"); assert(imapCredentialsOnlyPanel.includes('placeholder="Saved IMAP password"'), "focused credentials panel preserves saved password UX");
assert(!imapCredentialsOnlyPanel.includes("smtp.example.org"), "focused IMAP credentials panel does not render SMTP fields"); assert(!imapCredentialsOnlyPanel.includes("smtp.example.org"), "focused IMAP credentials panel does not render SMTP fields");

View File

@@ -18,6 +18,7 @@
"jsx": "react-jsx" "jsx": "react-jsx"
}, },
"include": [ "include": [
"tests/data-grid-actions.test.tsx",
"tests/mail-components.test.tsx", "tests/mail-components.test.tsx",
"src/components/CredentialPanel.tsx", "src/components/CredentialPanel.tsx",
"src/components/email/EmailAddressInput.tsx", "src/components/email/EmailAddressInput.tsx",