From 9c2b84a64a6e07d236361e97f869a3d62f7388a5 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 24 Jun 2026 20:02:49 +0200 Subject: [PATCH] Prepare v0.1.0 release dependencies --- README.md | 4 +- docs/MODULE_ARCHITECTURE.md | 10 ++ docs/RELEASE_DEPENDENCIES.md | 50 ++++++ requirements-dev.txt | 2 +- requirements-release.txt | 6 + requirements.txt | 2 +- src/govoplan_core/api/v1/admin.py | 26 +++ src/govoplan_core/api/v1/admin_schemas.py | 9 ++ src/govoplan_core/privacy/retention.py | 87 ++++++++++ tests/test_api_smoke.py | 152 ++++++++++++++++++ webui/package.release.json | 49 ++++++ webui/src/api/admin.ts | 9 ++ webui/src/api/client.ts | 135 ++++++++++++++-- webui/src/components/EffectivePolicyBlock.tsx | 24 +++ webui/src/components/PasswordField.tsx | 64 ++++++++ webui/src/components/PolicyLockedHint.tsx | 11 ++ webui/src/components/PolicySourcePath.tsx | 66 ++++++++ webui/src/components/help/InlineHelp.tsx | 2 +- .../mail/MailServerSettingsPanel.tsx | 31 +++- webui/src/features/admin/SystemUsersPanel.tsx | 12 +- webui/src/features/admin/TenantsPanel.tsx | 28 +++- webui/src/features/admin/UsersPanel.tsx | 12 +- webui/src/features/auth/LoginModal.tsx | 3 +- .../privacy/RetentionPolicyManagement.tsx | 47 ++++-- webui/src/features/settings/SettingsPage.tsx | 7 +- webui/src/index.ts | 10 ++ webui/src/styles/components.css | 90 +++++++++++ 27 files changed, 900 insertions(+), 48 deletions(-) create mode 100644 docs/RELEASE_DEPENDENCIES.md create mode 100644 requirements-release.txt create mode 100644 webui/package.release.json create mode 100644 webui/src/components/EffectivePolicyBlock.tsx create mode 100644 webui/src/components/PasswordField.tsx create mode 100644 webui/src/components/PolicyLockedHint.tsx create mode 100644 webui/src/components/PolicySourcePath.tsx diff --git a/README.md b/README.md index 6ffba86..3bb4a43 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:ap Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running. -`requirements-dev.txt` currently links the local `govoplan-files`, `govoplan-mail`, and `govoplan-campaign` checkouts for development. Production deployments should use tagged git dependencies or published packages. +`requirements-dev.txt` links local `govoplan-files`, `govoplan-mail`, and `govoplan-campaign` checkouts for development. `requirements-release.txt` installs those modules from tagged git refs for release builds. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md). ## WebUI development @@ -60,7 +60,7 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run dev ``` -The host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. +The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md). ## Module contract diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index 1e6d33c..d9a629f 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -174,3 +174,13 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio ``` Clean generated `dist`, `.vite`, and source-tree `__pycache__` artifacts after verification unless they are intentionally part of a release artifact. + +## Release Dependency Rules + +Local development may use editable Python installs and local WebUI `file:` dependencies so sibling module changes reload quickly. Release builds must use tagged git refs or published packages instead. Core provides: + +- `requirements-dev.txt` for local editable backend installs +- `requirements-release.txt` for tagged backend module installs +- `webui/package.release.json` for tagged WebUI module installs + +Module repositories include root-level npm manifests for git installs. When cutting a release, update the Python versions, WebUI versions, release dependency refs, and repository tags together. diff --git a/docs/RELEASE_DEPENDENCIES.md b/docs/RELEASE_DEPENDENCIES.md new file mode 100644 index 0000000..9a41c69 --- /dev/null +++ b/docs/RELEASE_DEPENDENCIES.md @@ -0,0 +1,50 @@ +# GovOPlaN Release Dependencies + +Release installs must not depend on sibling checkout paths. Local development can keep editable installs and `file:` WebUI links, but release packaging should resolve modules from tagged git refs or from a package registry. + +## Backend + +Local development: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m pip install -r requirements-dev.txt +``` + +Release install from tagged module repositories: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m pip install -r requirements-release.txt +``` + +`requirements-release.txt` pins the module repositories to the release tag. Update those refs when cutting a release: + +```text +govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.0 +govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.0 +govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.0 +``` + +## WebUI + +Local development uses `webui/package.json`, which may point at sibling module checkouts while active development is happening. + +Release WebUI installs should use `webui/package.release.json`. It points module dependencies at the same tagged git repositories. To generate a release lockfile, copy it over `package.json` in a release branch or build workspace and then run `npm install` there: + +```bash +cd /mnt/DATA/git/govoplan-core/webui +cp package.release.json package.json +PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm install +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 installs can resolve `@govoplan/files-webui`, `@govoplan/mail-webui`, and `@govoplan/campaign-webui` from repository roots even though their source lives below `webui/src`. + +## Release Checklist + +- Keep Python package versions, WebUI package versions, and git tags aligned. +- Tag core, files, mail, and campaign repositories together. +- Update `requirements-release.txt` and `webui/package.release.json` when the release tag changes. +- Generate release lockfiles from release manifests in a clean build workspace. +- Do not commit local sibling paths into release manifests. diff --git a/requirements-dev.txt b/requirements-dev.txt index 6244d6a..ea5510e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ --r requirements.txt +-e .[server] -e ../govoplan-files -e ../govoplan-mail -e ../govoplan-campaign diff --git a/requirements-release.txt b/requirements-release.txt new file mode 100644 index 0000000..381a431 --- /dev/null +++ b/requirements-release.txt @@ -0,0 +1,6 @@ +# Release install from tagged module repositories. +# Update GOVOPLAN_RELEASE_TAG together with pyproject/package versions when cutting a release. +.[server] +govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.0 +govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.0 +govoplan-campaign @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git@v0.1.0 diff --git a/requirements.txt b/requirements.txt index bfd97f6..2af3258 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ --e .[server] +.[server] diff --git a/src/govoplan_core/api/v1/admin.py b/src/govoplan_core/api/v1/admin.py index 240bb4d..1eacd99 100644 --- a/src/govoplan_core/api/v1/admin.py +++ b/src/govoplan_core/api/v1/admin.py @@ -117,8 +117,10 @@ from govoplan_core.privacy.retention import ( PrivacyPolicyError, apply_retention_policy, effective_privacy_policy, + effective_privacy_policy_sources, get_privacy_policy_for_scope, parent_privacy_policy, + parent_privacy_policy_sources, privacy_policy_from_settings, set_privacy_policy, set_privacy_policy_for_scope, @@ -1235,6 +1237,8 @@ def read_privacy_retention_policy( policy=policy, effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, + effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), + parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), ) except PrivacyPolicyError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -1267,12 +1271,34 @@ def write_privacy_retention_policy( policy=policy, effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, + effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), + parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), ) except PrivacyPolicyError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + +def _parent_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): + if scope_type == "system": + return [] + return parent_privacy_policy_sources(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None)) + + +def _effective_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): + if scope_type == "system": + return effective_privacy_policy_sources(session) + if scope_type == "tenant": + return effective_privacy_policy_sources(session, tenant_id=scope_id or tenant_id) + if scope_type == "campaign" and scope_id: + return effective_privacy_policy_sources(session, campaign_id=scope_id) + if scope_type == "user" and scope_id: + return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_user_id=scope_id) + if scope_type == "group" and scope_id: + return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_group_id=scope_id) + return effective_privacy_policy_sources(session, tenant_id=tenant_id) + def _parent_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): if scope_type == "system": return None diff --git a/src/govoplan_core/api/v1/admin_schemas.py b/src/govoplan_core/api/v1/admin_schemas.py index 3932e9d..44f5a9f 100644 --- a/src/govoplan_core/api/v1/admin_schemas.py +++ b/src/govoplan_core/api/v1/admin_schemas.py @@ -312,6 +312,13 @@ class SystemAccountCreateResponse(BaseModel): temporary_password: str | None = None +class PolicySourceStepItem(BaseModel): + scope_type: str + scope_id: str | None = None + label: str + applied_fields: list[str] = Field(default_factory=list) + + class PrivacyRetentionPolicyItem(BaseModel): model_config = ConfigDict(extra="forbid") @@ -360,6 +367,8 @@ class PrivacyRetentionPolicyScopeResponse(BaseModel): policy: dict[str, Any] effective_policy: PrivacyRetentionPolicyItem parent_policy: PrivacyRetentionPolicyItem | None = None + effective_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list) + parent_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list) class RetentionRunRequest(BaseModel): diff --git a/src/govoplan_core/privacy/retention.py b/src/govoplan_core/privacy/retention.py index 834b4c7..071c071 100644 --- a/src/govoplan_core/privacy/retention.py +++ b/src/govoplan_core/privacy/retention.py @@ -304,6 +304,93 @@ def parent_privacy_policy(session: Session, *, tenant_id: str, scope_type: str, return policy + +def _retention_policy_source_fields(patch: dict[str, Any], *, baseline: bool = False) -> list[str]: + fields: list[str] = [] + for key in RETENTION_POLICY_FIELD_KEYS: + if key in patch and patch.get(key) is not None: + fields.append(key) + if isinstance(patch.get("allow_lower_level_limits"), dict) and patch["allow_lower_level_limits"]: + fields.append("allow_lower_level_limits") + if baseline and not fields: + fields.append("defaults") + return fields + + +def _retention_policy_source_step(scope_type: str, label: str, scope_id: str | None, patch: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]: + return { + "scope_type": scope_type, + "scope_id": scope_id, + "label": label, + "applied_fields": _retention_policy_source_fields(patch, baseline=baseline), + } + + +def effective_privacy_policy_sources( + session: Session, + *, + tenant_id: str | None = None, + owner_user_id: str | None = None, + owner_group_id: str | None = None, + campaign_id: str | None = None, +) -> list[dict[str, Any]]: + system_settings = get_system_settings(session) + sources = [_retention_policy_source_step("system", "System", None, _privacy_policy_patch_from_settings(system_settings.settings or {}), baseline=True)] + campaign: Campaign | None = None + if campaign_id: + campaign = session.get(Campaign, campaign_id) + if campaign is None: + raise PrivacyPolicyError("Campaign not found for privacy policy") + tenant_id = campaign.tenant_id + owner_user_id = campaign.owner_user_id + owner_group_id = campaign.owner_group_id + if tenant_id: + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise PrivacyPolicyError("Tenant not found for privacy policy") + sources.append(_retention_policy_source_step("tenant", "Tenant", tenant.id, _privacy_policy_patch_from_settings(tenant.settings or {}))) + if owner_user_id: + user = session.get(User, owner_user_id) + if user and (not tenant_id or user.tenant_id == tenant_id): + sources.append(_retention_policy_source_step("user", "Owner user", user.id, _privacy_policy_patch_from_settings(user.settings or {}))) + if owner_group_id: + group = session.get(Group, owner_group_id) + if group and (not tenant_id or group.tenant_id == tenant_id): + sources.append(_retention_policy_source_step("group", "Owner group", group.id, _privacy_policy_patch_from_settings(group.settings or {}))) + if campaign is not None: + sources.append(_retention_policy_source_step("campaign", "Campaign", campaign.id, _privacy_policy_patch_from_settings(campaign.settings or {}))) + return sources + + +def parent_privacy_policy_sources(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> list[dict[str, Any]]: + clean_scope = scope_type.strip().casefold() + if clean_scope == "system": + return [] + system_settings = get_system_settings(session) + sources = [_retention_policy_source_step("system", "System", None, _privacy_policy_patch_from_settings(system_settings.settings or {}), baseline=True)] + if clean_scope == "tenant": + return sources + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise PrivacyPolicyError("Tenant not found for privacy policy") + sources.append(_retention_policy_source_step("tenant", "Tenant", tenant.id, _privacy_policy_patch_from_settings(tenant.settings or {}))) + if clean_scope in {"user", "group"}: + return sources + if clean_scope != "campaign" or not scope_id: + return sources + campaign = session.get(Campaign, scope_id) + if campaign is None or campaign.tenant_id != tenant_id: + raise PrivacyPolicyError("Campaign not found for privacy policy") + if campaign.owner_user_id: + user = session.get(User, campaign.owner_user_id) + if user and user.tenant_id == tenant_id: + sources.append(_retention_policy_source_step("user", "Owner user", user.id, _privacy_policy_patch_from_settings(user.settings or {}))) + if campaign.owner_group_id: + group = session.get(Group, campaign.owner_group_id) + if group and group.tenant_id == tenant_id: + sources.append(_retention_policy_source_step("group", "Owner group", group.id, _privacy_policy_patch_from_settings(group.settings or {}))) + return sources + def get_privacy_policy_for_scope(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> dict[str, Any]: clean_scope = scope_type.strip().casefold() if clean_scope == "system": diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 3bf7c3d..87a1d21 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -205,6 +205,55 @@ class ApiSmokeTests(unittest.TestCase): self.assertEqual(allowed.status_code, 200, allowed.text) self.assertEqual(allowed.json()["user"]["display_name"], "Cookie Admin") + def test_mailbox_message_listing_reports_total_count(self) -> None: + headers, _ = self._login() + created_profile = self.client.post( + "/api/v1/mail/profiles", + headers=headers, + json={ + "name": "Readable mailbox", + "smtp": { + "host": "mock.smtp", + "port": 2525, + "username": "sender@example.org", + "password": "smtp-secret", + "security": "starttls", + }, + "imap": { + "enabled": True, + "host": "mock.imap", + "port": 993, + "username": "sender@example.org", + "password": "imap-secret", + "security": "tls", + "sent_folder": "Sent", + }, + }, + ) + self.assertEqual(created_profile.status_code, 201, created_profile.text) + profile_id = created_profile.json()["id"] + + from govoplan_mail.backend.dev.mock_mailbox import record_imap_append + + for index in range(3): + record_imap_append( + f"Subject: Mock message {index + 1}\nFrom: sender@example.org\nTo: recipient@example.org\n\nBody {index + 1}".encode("utf-8"), + folder="INBOX", + imap_host="mock.imap", + ) + + listed = self.client.get( + f"/api/v1/mail/profiles/{profile_id}/mailbox/messages", + headers=headers, + params={"folder": "INBOX", "limit": 2}, + ) + self.assertEqual(listed.status_code, 200, listed.text) + payload = listed.json() + self.assertEqual(payload["folder"], "INBOX") + self.assertEqual(payload["total_count"], 3) + self.assertEqual(len(payload["messages"]), 2) + self.assertTrue(all(message["folder"] == "INBOX" for message in payload["messages"])) + def test_encrypted_mail_profile_can_back_campaign_without_exposing_secrets(self) -> None: headers, _ = self._login() created_profile = self.client.post( @@ -2450,6 +2499,37 @@ class ApiSmokeTests(unittest.TestCase): self.assertEqual(denied_recipient_edit.status_code, 403, denied_recipient_edit.text) self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=other_headers).status_code, 403) + def test_policy_responses_include_source_provenance(self) -> None: + headers, _ = self._login() + + retention = self.client.get("/api/v1/admin/privacy-retention/policies/tenant", headers=headers) + self.assertEqual(retention.status_code, 200, retention.text) + retention_payload = retention.json() + self.assertEqual([item["label"] for item in retention_payload["effective_policy_sources"]], ["System", "Tenant"]) + self.assertEqual([item["label"] for item in retention_payload["parent_policy_sources"]], ["System"]) + self.assertIn("defaults", retention_payload["effective_policy_sources"][0]["applied_fields"]) + + created = self.client.post( + "/api/v1/campaigns/new", + headers=headers, + json={"external_id": "policy-source-campaign", "name": "Policy source campaign"}, + ) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + + updated = self.client.put( + f"/api/v1/mail/policies/campaign?scope_id={campaign_id}", + headers=headers, + json={"policy": {"allow_campaign_profiles": False}}, + ) + self.assertEqual(updated.status_code, 200, updated.text) + payload = updated.json() + self.assertEqual([item["label"] for item in payload["effective_policy_sources"]], ["System", "Tenant", "Owner user", "Campaign"]) + campaign_source = payload["effective_policy_sources"][-1] + self.assertEqual(campaign_source["scope_type"], "campaign") + self.assertEqual(campaign_source["scope_id"], campaign_id) + self.assertIn("allow_campaign_profiles", campaign_source["applied_fields"]) + def test_campaign_scoped_mail_profile_policy_is_enforced(self) -> None: headers, _ = self._login() created = self.client.post( @@ -2498,6 +2578,78 @@ class ApiSmokeTests(unittest.TestCase): self.assertEqual(blocked.status_code, 422, blocked.text) self.assertIn("blacklist", blocked.json()["detail"]) + def test_campaign_local_mail_policy_blocks_inline_but_allows_reusable_profiles(self) -> None: + headers, _ = self._login() + created = self.client.post( + "/api/v1/campaigns/new", + headers=headers, + json={"external_id": "campaign-local-mail-policy", "name": "Campaign local mail policy"}, + ) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + version_id = created.json()["version"]["id"] + + tenant_profile = self.client.post( + "/api/v1/mail/profiles", + headers=headers, + json={ + "name": "Tenant reusable SMTP", + "smtp": {"host": "tenant.smtp.local", "port": 2525, "username": "sender@example.org", "password": "secret", "security": "starttls"}, + }, + ) + self.assertEqual(tenant_profile.status_code, 201, tenant_profile.text) + tenant_profile_id = tenant_profile.json()["id"] + + policy = self.client.put( + f"/api/v1/mail/policies/campaign?scope_id={campaign_id}", + headers=headers, + json={"policy": {"allow_campaign_profiles": False}}, + ) + self.assertEqual(policy.status_code, 200, policy.text) + self.assertFalse(policy.json()["effective_policy"]["allow_campaign_profiles"]) + + blocked_profile = self.client.post( + "/api/v1/mail/profiles", + headers=headers, + json={ + "name": "Blocked campaign SMTP", + "scope_type": "campaign", + "scope_id": campaign_id, + "smtp": {"host": "campaign.smtp.local", "port": 2525, "username": "sender@example.org", "password": "secret", "security": "starttls"}, + }, + ) + self.assertEqual(blocked_profile.status_code, 422, blocked_profile.text) + self.assertIn("disabled by policy", blocked_profile.json()["detail"]) + + detail = self.client.get(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=headers) + self.assertEqual(detail.status_code, 200, detail.text) + inline_json = detail.json()["raw_json"] + inline_json["server"] = { + "smtp": { + "host": "campaign.smtp.local", + "port": 2525, + "username": "sender@example.org", + "password": "secret", + "security": "starttls", + } + } + blocked_inline = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", + headers=headers, + json={"campaign_json": inline_json}, + ) + self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text) + self.assertIn("Campaign-local inline mail settings", blocked_inline.json()["detail"]) + + reusable_json = detail.json()["raw_json"] + reusable_json["server"] = {"mail_profile_id": tenant_profile_id} + allowed_reusable = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", + headers=headers, + json={"campaign_json": reusable_json}, + ) + self.assertEqual(allowed_reusable.status_code, 200, allowed_reusable.text) + def test_allowed_mail_profile_ids_gate_campaign_selection(self) -> None: headers, _ = self._login() created = self.client.post( diff --git a/webui/package.release.json b/webui/package.release.json new file mode 100644 index 0000000..d069687 --- /dev/null +++ b/webui/package.release.json @@ -0,0 +1,49 @@ +{ + "name": "@govoplan/core-webui", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./app": { + "types": "./src/app.ts", + "import": "./src/app.ts" + } + }, + "scripts": { + "dev": "vite --host 127.0.0.1 --port 5173", + "build": "tsc && vite build", + "preview": "vite preview --host 127.0.0.1 --port 4173" + }, + "dependencies": { + "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.0", + "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.0", + "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.0" + }, + "devDependencies": { + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependencies": { + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "allowScripts": { + "esbuild@0.25.12": true + } +} diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index 2dccc16..00bb44a 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -147,12 +147,21 @@ export type PrivacyRetentionPolicyPatch = Partial>(); +const recentSafeRequests = new Map(); +let safeRequestGeneration = 0; export class ApiError extends Error { readonly status: number; @@ -91,6 +102,63 @@ function isUnsafeMethod(method?: string): boolean { return !["GET", "HEAD", "OPTIONS", "TRACE"].includes(normalized); } +function canReuseSafeRequest(method: string, init?: RequestInit): boolean { + return (method === "GET" || method === "HEAD") + && !init?.body + && !init?.signal + && init?.cache !== "no-store" + && init?.cache !== "reload"; +} + +function requestHeadersKey(headers: Headers): string { + return [...headers.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}:${value}`) + .join("\n"); +} + +function safeRequestKey(url: string, method: string, headers: Headers, init?: RequestInit): string { + return [ + method, + url, + init?.credentials ?? "include", + init?.mode ?? "", + init?.redirect ?? "", + requestHeadersKey(headers), + ].join("\n\n"); +} + +function pruneRecentSafeRequests(now = Date.now()): void { + for (const [key, entry] of recentSafeRequests) { + if (entry.expiresAt <= now) { + recentSafeRequests.delete(key); + } + } + + while (recentSafeRequests.size > MAX_RECENT_SAFE_REQUESTS) { + const oldestKey = recentSafeRequests.keys().next().value; + if (!oldestKey) break; + recentSafeRequests.delete(oldestKey); + } +} + +function recentSafeResponse(key: string): unknown | undefined { + const now = Date.now(); + const recent = recentSafeRequests.get(key); + if (!recent) return undefined; + if (recent.expiresAt <= now) { + recentSafeRequests.delete(key); + return undefined; + } + return recent.value; +} + +function rememberSafeResponse(key: string, value: unknown): void { + const now = Date.now(); + recentSafeRequests.set(key, { value, expiresAt: now + RECENT_SAFE_REQUEST_TTL_MS }); + pruneRecentSafeRequests(now); +} + export function authHeaders(settings: ApiSettings): Headers { const headers = new Headers(); if (settings.accessToken) { @@ -103,6 +171,7 @@ export function authHeaders(settings: ApiSettings): Headers { export async function apiFetch(settings: ApiSettings, path: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers || {}); + const method = (init?.method || "GET").toUpperCase(); if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); @@ -113,27 +182,69 @@ export async function apiFetch(settings: ApiSettings, path: string, init?: Re } const csrf = csrfToken(); - if (csrf && isUnsafeMethod(init?.method) && !headers.has("X-CSRF-Token")) { + if (csrf && isUnsafeMethod(method) && !headers.has("X-CSRF-Token")) { headers.set("X-CSRF-Token", csrf); } - const response = await fetch(apiUrl(settings, path), { ...init, headers, credentials: init?.credentials ?? "include" }); - - if (!response.ok) { - const text = await response.text(); - throw new ApiError(response.status, response.statusText, text); + if (isUnsafeMethod(method)) { + safeRequestGeneration += 1; + inFlightSafeRequests.clear(); + recentSafeRequests.clear(); } - if (response.status === 204) { - return undefined as T; + const url = apiUrl(settings, path); + const fetchInit = { ...init, headers, credentials: init?.credentials ?? "include" }; + + async function runFetch(): Promise { + const response = await fetch(url, fetchInit); + + if (!response.ok) { + const text = await response.text(); + throw new ApiError(response.status, response.statusText, text); + } + + if (response.status === 204) { + return undefined as T; + } + + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) { + return (await response.text()) as T; + } + + return (await response.json()) as T; } - const contentType = response.headers.get("content-type") || ""; - if (!contentType.includes("application/json")) { - return (await response.text()) as T; + if (!canReuseSafeRequest(method, init)) { + return runFetch(); } - return (await response.json()) as T; + const cacheKey = safeRequestKey(url, method, headers, init); + const requestGeneration = safeRequestGeneration; + const recent = recentSafeResponse(cacheKey); + if (recent !== undefined) { + return recent as T; + } + + const existing = inFlightSafeRequests.get(cacheKey); + if (existing) { + return existing as Promise; + } + + const request = runFetch() + .then((value) => { + if (requestGeneration === safeRequestGeneration) { + rememberSafeResponse(cacheKey, value); + } + return value; + }) + .finally(() => { + if (inFlightSafeRequests.get(cacheKey) === request) { + inFlightSafeRequests.delete(cacheKey); + } + }); + inFlightSafeRequests.set(cacheKey, request); + return request; } diff --git a/webui/src/components/EffectivePolicyBlock.tsx b/webui/src/components/EffectivePolicyBlock.tsx new file mode 100644 index 0000000..d66907c --- /dev/null +++ b/webui/src/components/EffectivePolicyBlock.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; +import PolicySourcePath, { type PolicySourcePathItem } from "./PolicySourcePath"; + +export type EffectivePolicyBlockProps = { + title: string; + sourcePath?: PolicySourcePathItem[]; + children: ReactNode; + className?: string; + gridClassName?: string; +}; + +export function EffectivePolicyValue({ label, value }: { label: string; value: ReactNode }) { + return
{label}{value}
; +} + +export default function EffectivePolicyBlock({ title, sourcePath, children, className = "", gridClassName = "" }: EffectivePolicyBlockProps) { + return ( +
+

{title}

+ {sourcePath && } +
{children}
+
+ ); +} diff --git a/webui/src/components/PasswordField.tsx b/webui/src/components/PasswordField.tsx new file mode 100644 index 0000000..cdaf91a --- /dev/null +++ b/webui/src/components/PasswordField.tsx @@ -0,0 +1,64 @@ +import { useId, useState, type InputHTMLAttributes } from "react"; +import { Eye, EyeOff } from "lucide-react"; + +export type PasswordFieldProps = Omit, "type" | "value" | "onChange"> & { + value: string; + onValueChange: (value: string) => void; + saved?: boolean; + savedPlaceholder?: string; + revealLabel?: string; + hideLabel?: string; + inputClassName?: string; +}; + +export default function PasswordField({ + value, + onValueChange, + saved = false, + savedPlaceholder = "••••••••", + revealLabel = "Show password", + hideLabel = "Hide password", + placeholder, + disabled = false, + className = "", + inputClassName = "", + id, + ...inputProps +}: PasswordFieldProps) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const [visible, setVisible] = useState(false); + const hasTypedPassword = value.length > 0; + const showSavedPlaceholder = saved && !hasTypedPassword; + const canReveal = hasTypedPassword && !disabled; + const inputType = visible && canReveal ? "text" : "password"; + + return ( +
+ { + onValueChange(event.target.value); + if (!event.target.value) setVisible(false); + }} + /> + {canReveal && ( + + )} +
+ ); +} diff --git a/webui/src/components/PolicyLockedHint.tsx b/webui/src/components/PolicyLockedHint.tsx new file mode 100644 index 0000000..7991d61 --- /dev/null +++ b/webui/src/components/PolicyLockedHint.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +export type PolicyLockedHintProps = { + children: ReactNode; + className?: string; +}; + +export default function PolicyLockedHint({ children, className = "" }: PolicyLockedHintProps) { + if (!children) return null; + return

{children}

; +} diff --git a/webui/src/components/PolicySourcePath.tsx b/webui/src/components/PolicySourcePath.tsx new file mode 100644 index 0000000..121c1d6 --- /dev/null +++ b/webui/src/components/PolicySourcePath.tsx @@ -0,0 +1,66 @@ +export type PolicySourcePathItem = string | { + scope_type?: string; + scope_id?: string | null; + label: string; + applied_fields?: string[]; + appliedFields?: string[]; +}; + +export type PolicySourcePathProps = { + items: PolicySourcePathItem[]; + label?: string; + className?: string; +}; + +type NormalizedPolicySourcePathItem = { + label: string; + appliedFields: string[]; +}; + +export default function PolicySourcePath({ items, label = "Source path", className = "" }: PolicySourcePathProps) { + const cleanItems = items.map(normalizeSourceItem).filter((item): item is NormalizedPolicySourcePathItem => Boolean(item?.label)); + if (cleanItems.length === 0) return null; + + return ( +
+ {label} +
    + {cleanItems.map((item, index) => ( +
  1. + {item.label} + {item.appliedFields.length > 0 && {sourceFieldSummary(item.appliedFields)}} +
  2. + ))} +
+
+ ); +} + +function normalizeSourceItem(item: PolicySourcePathItem): NormalizedPolicySourcePathItem | null { + if (typeof item === "string") { + const label = item.trim(); + return label ? { label, appliedFields: [] } : null; + } + const label = item.label?.trim(); + if (!label) return null; + const appliedFields = [...(item.applied_fields ?? item.appliedFields ?? [])].map((field) => field.trim()).filter(Boolean); + return { label, appliedFields }; +} + +function sourceFieldSummary(fields: string[]): string { + const unique = Array.from(new Set(fields)); + if (unique.length === 0) return ""; + if (unique.length === 1 && unique[0] === "defaults") return "defaults"; + if (unique.length <= 3) return unique.map(sourceFieldLabel).join(", "); + return `${unique.slice(0, 2).map(sourceFieldLabel).join(", ")} +${unique.length - 2}`; +} + +function sourceFieldLabel(field: string): string { + if (field === "defaults") return "defaults"; + const clean = field + .replace(/^whitelist[.]/, "allow ") + .replace(/^blacklist[.]/, "block ") + .replace(/[_.]/g, " ") + .trim(); + return clean.charAt(0).toUpperCase() + clean.slice(1); +} diff --git a/webui/src/components/help/InlineHelp.tsx b/webui/src/components/help/InlineHelp.tsx index b376040..8d939f0 100644 --- a/webui/src/components/help/InlineHelp.tsx +++ b/webui/src/components/help/InlineHelp.tsx @@ -20,7 +20,7 @@ const TRIGGER_GAP = 10; const tooltipBaseStyle: CSSProperties = { position: "fixed", - zIndex: 10000, + zIndex: 20000, width: "max-content", maxWidth: "min(320px, calc(100vw - 48px))", border: "1px solid var(--line-dark)", diff --git a/webui/src/components/mail/MailServerSettingsPanel.tsx b/webui/src/components/mail/MailServerSettingsPanel.tsx index ccdaf16..130b22e 100644 --- a/webui/src/components/mail/MailServerSettingsPanel.tsx +++ b/webui/src/components/mail/MailServerSettingsPanel.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import Button from "../Button"; import DismissibleAlert from "../DismissibleAlert"; import FormField from "../FormField"; +import PasswordField from "../PasswordField"; import ToggleSwitch from "../ToggleSwitch"; export type MailServerSecurity = "plain" | "tls" | "starttls" | string; @@ -50,10 +51,14 @@ export type MailServerSettingsPanelProps = { onImapEnabledChange?: (enabled: boolean) => void; smtpDisabled?: boolean; smtpCredentialDisabled?: boolean; + smtpPasswordSaved?: boolean; + smtpSavedPasswordPlaceholder?: string; smtpActionDisabled?: boolean; imapToggleDisabled?: boolean; imapServerDisabled?: boolean; imapCredentialDisabled?: boolean; + imapPasswordSaved?: boolean; + imapSavedPasswordPlaceholder?: string; imapActionDisabled?: boolean; smtpTitle?: ReactNode; imapTitle?: ReactNode; @@ -98,10 +103,14 @@ export default function MailServerSettingsPanel({ onImapEnabledChange, smtpDisabled = false, smtpCredentialDisabled = smtpDisabled, + smtpPasswordSaved = false, + smtpSavedPasswordPlaceholder = "••••••••", smtpActionDisabled = smtpDisabled, imapToggleDisabled = false, imapServerDisabled = false, imapCredentialDisabled = imapServerDisabled, + imapPasswordSaved = false, + imapSavedPasswordPlaceholder = "••••••••", imapActionDisabled = imapServerDisabled, smtpTitle = "SMTP login", imapTitle = "IMAP sent-folder append", @@ -149,7 +158,16 @@ export default function MailServerSettingsPanel({ onSmtpChange({ host: event.target.value })} /> onSmtpChange({ port: event.target.value })} /> onSmtpChange({ username: event.target.value })} /> - onSmtpChange({ password: event.target.value })} /> + + onSmtpChange({ password })} + /> + onSmtpChange({ security })} /> onSmtpChange({ timeout_seconds: event.target.value })} /> @@ -174,7 +192,16 @@ export default function MailServerSettingsPanel({ onImapChange({ host: event.target.value })} /> onImapChange({ port: event.target.value })} /> onImapChange({ username: event.target.value })} /> - onImapChange({ password: event.target.value })} /> + + onImapChange({ password })} + /> + onImapChange({ security })} /> onImapChange({ sent_folder: event.target.value })} /> onImapChange({ timeout_seconds: event.target.value })} /> diff --git a/webui/src/features/admin/SystemUsersPanel.tsx b/webui/src/features/admin/SystemUsersPanel.tsx index bde814e..90b6609 100644 --- a/webui/src/features/admin/SystemUsersPanel.tsx +++ b/webui/src/features/admin/SystemUsersPanel.tsx @@ -6,6 +6,7 @@ import ConfirmDialog from "../../components/ConfirmDialog"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import Dialog from "../../components/Dialog"; import FormField from "../../components/FormField"; +import PasswordField from "../../components/PasswordField"; import StatusBadge from "../../components/StatusBadge"; import { createSystemAccount, @@ -192,7 +193,16 @@ export default function SystemUsersPanel({
setDraft({ ...draft, email: event.target.value })} /> setDraft({ ...draft, displayName: event.target.value })} /> - {editing === "new" && setDraft({ ...draft, password: event.target.value })} />} + {editing === "new" && ( + + setDraft({ ...draft, password })} + /> + + )}
diff --git a/webui/src/features/admin/TenantsPanel.tsx b/webui/src/features/admin/TenantsPanel.tsx index 9ea49fe..c20e9f5 100644 --- a/webui/src/features/admin/TenantsPanel.tsx +++ b/webui/src/features/admin/TenantsPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import type { ApiSettings, AuthInfo } from "../../types"; -import { createTenant, fetchTenantOwnerCandidates, fetchTenants, updateTenant, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin"; +import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenants, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin"; import Button from "../../components/Button"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import Dialog from "../../components/Dialog"; @@ -65,6 +65,7 @@ export default function TenantsPanel({ onAuthRefresh: () => Promise; }) { const [tenants, setTenants] = useState([]); + const [systemSettings, setSystemSettings] = useState(null); const [ownerCandidates, setOwnerCandidates] = useState([]); const [editing, setEditing] = useState(null); const [viewing, setViewing] = useState(null); @@ -79,12 +80,14 @@ export default function TenantsPanel({ setLoading(true); setError(""); try { - const [nextTenants, nextOwnerCandidates] = await Promise.all([ + const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([ fetchTenants(settings), - canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]) + canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]), + fetchSystemSettings(settings).catch(() => null) ]); setTenants(nextTenants); setOwnerCandidates(nextOwnerCandidates); + setSystemSettings(nextSystemSettings); } catch (err) { setError(adminErrorMessage(err)); } finally { @@ -180,6 +183,14 @@ export default function TenantsPanel({ } const activeTenantId = (auth.active_tenant ?? auth.tenant).id; + const systemAllowsCustomGroups = systemSettings?.allow_tenant_custom_groups !== false; + const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false; + const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false; + const systemDeniedGovernance = [ + systemAllowsCustomGroups ? "" : "custom groups", + systemAllowsCustomRoles ? "" : "custom roles", + systemAllowsApiKeys ? "" : "API keys" + ].filter(Boolean).join(", "); const columns = useMemo[]>(() => [ { id: "name", header: "Tenant", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) =>
{row.name}
{row.slug}
}, { id: "users", header: "Users", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.users ?? 0, render: (row) => `${row.counts.active_users ?? 0}/${row.counts.users ?? 0}` }, @@ -219,11 +230,12 @@ export default function TenantsPanel({

System governance overrides

- setDraft({ ...draft, customGroups })} /> - setDraft({ ...draft, customRoles })} /> - setDraft({ ...draft, apiKeys })} /> + setDraft({ ...draft, customGroups })} /> + setDraft({ ...draft, customRoles })} /> + setDraft({ ...draft, apiKeys })} />

Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.

+ {systemDeniedGovernance &&

Explicit allow is unavailable for {systemDeniedGovernance} because the current system setting denies it.

} setViewing(null)} className="admin-dialog admin-dialog-wide" footer={}> @@ -243,6 +255,6 @@ export default function TenantsPanel({ ); } -function GovernanceSelect({ label, value, onChange, disabled = false }: { label: string; value: OverrideValue; onChange: (value: OverrideValue) => void; disabled?: boolean }) { - return ; +function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: { label: string; value: OverrideValue; onChange: (value: OverrideValue) => void; disabled?: boolean; allowDisabled?: boolean }) { + return ; } diff --git a/webui/src/features/admin/UsersPanel.tsx b/webui/src/features/admin/UsersPanel.tsx index 362834c..b4f127b 100644 --- a/webui/src/features/admin/UsersPanel.tsx +++ b/webui/src/features/admin/UsersPanel.tsx @@ -6,6 +6,7 @@ import Button from "../../components/Button"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import Dialog from "../../components/Dialog"; import FormField from "../../components/FormField"; +import PasswordField from "../../components/PasswordField"; import StatusBadge from "../../components/StatusBadge"; import ConfirmDialog from "../../components/ConfirmDialog"; import AdminSelectionList from "./components/AdminSelectionList"; @@ -151,7 +152,16 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
setDraft({ ...draft, email: event.target.value })} /> setDraft({ ...draft, displayName: event.target.value })} /> - {editing === "new" && setDraft({ ...draft, password: event.target.value })} />} + {editing === "new" && ( + + setDraft({ ...draft, password })} + /> + + )}
{editing === "new" && } diff --git a/webui/src/features/auth/LoginModal.tsx b/webui/src/features/auth/LoginModal.tsx index e3a7f0c..c762d50 100644 --- a/webui/src/features/auth/LoginModal.tsx +++ b/webui/src/features/auth/LoginModal.tsx @@ -3,6 +3,7 @@ import type { ApiSettings, LoginResponse } from "../../types"; import { login } from "../../api/auth"; import Button from "../../components/Button"; import FormField from "../../components/FormField"; +import PasswordField from "../../components/PasswordField"; import DismissibleAlert from "../../components/DismissibleAlert"; export default function LoginModal({ @@ -47,7 +48,7 @@ export default function LoginModal({ setEmail(e.target.value)} /> - setPassword(e.target.value)} /> +