Prepare v0.1.0 release dependencies
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
50
docs/RELEASE_DEPENDENCIES.md
Normal file
50
docs/RELEASE_DEPENDENCIES.md
Normal file
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
-r requirements.txt
|
||||
-e .[server]
|
||||
-e ../govoplan-files
|
||||
-e ../govoplan-mail
|
||||
-e ../govoplan-campaign
|
||||
|
||||
6
requirements-release.txt
Normal file
6
requirements-release.txt
Normal file
@@ -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
|
||||
@@ -1 +1 @@
|
||||
-e .[server]
|
||||
.[server]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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(
|
||||
|
||||
49
webui/package.release.json
Normal file
49
webui/package.release.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -147,12 +147,21 @@ export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "
|
||||
};
|
||||
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||
|
||||
export type PolicySourceStep = {
|
||||
scope_type: string;
|
||||
scope_id?: string | null;
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyScopeResponse = {
|
||||
scope_type: PrivacyRetentionPolicyScope;
|
||||
scope_id?: string | null;
|
||||
policy: PrivacyRetentionPolicyPatch;
|
||||
effective_policy: PrivacyRetentionPolicy;
|
||||
parent_policy?: PrivacyRetentionPolicy | null;
|
||||
effective_policy_sources?: PolicySourceStep[];
|
||||
parent_policy_sources?: PolicySourceStep[];
|
||||
};
|
||||
|
||||
export type SystemSettingsItem = {
|
||||
|
||||
@@ -3,6 +3,17 @@ import type { ApiSettings } from "../types";
|
||||
const STORAGE_KEY = "multimailer.apiSettings";
|
||||
const SESSION_STORAGE_KEY = "multimailer.session";
|
||||
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
|
||||
const RECENT_SAFE_REQUEST_TTL_MS = 750;
|
||||
const MAX_RECENT_SAFE_REQUESTS = 100;
|
||||
|
||||
type RecentSafeRequest = {
|
||||
value: unknown;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const inFlightSafeRequests = new Map<string, Promise<unknown>>();
|
||||
const recentSafeRequests = new Map<string, RecentSafeRequest>();
|
||||
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<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
|
||||
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,11 +182,21 @@ export async function apiFetch<T>(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 (isUnsafeMethod(method)) {
|
||||
safeRequestGeneration += 1;
|
||||
inFlightSafeRequests.clear();
|
||||
recentSafeRequests.clear();
|
||||
}
|
||||
|
||||
const url = apiUrl(settings, path);
|
||||
const fetchInit = { ...init, headers, credentials: init?.credentials ?? "include" };
|
||||
|
||||
async function runFetch(): Promise<T> {
|
||||
const response = await fetch(url, fetchInit);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
@@ -134,6 +213,38 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
if (!canReuseSafeRequest(method, init)) {
|
||||
return runFetch();
|
||||
}
|
||||
|
||||
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<T>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
24
webui/src/components/EffectivePolicyBlock.tsx
Normal file
24
webui/src/components/EffectivePolicyBlock.tsx
Normal file
@@ -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 <div><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
export default function EffectivePolicyBlock({ title, sourcePath, children, className = "", gridClassName = "" }: EffectivePolicyBlockProps) {
|
||||
return (
|
||||
<section className={`policy-effective-block ${className}`.trim()}>
|
||||
<h3>{title}</h3>
|
||||
{sourcePath && <PolicySourcePath items={sourcePath} />}
|
||||
<div className={gridClassName || "policy-effective-grid"}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
64
webui/src/components/PasswordField.tsx
Normal file
64
webui/src/components/PasswordField.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useId, useState, type InputHTMLAttributes } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
export type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "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 (
|
||||
<div className={`password-field ${canReveal ? "has-toggle" : ""} ${showSavedPlaceholder ? "is-saved-empty" : ""} ${className}`.trim()}>
|
||||
<input
|
||||
{...inputProps}
|
||||
id={inputId}
|
||||
className={inputClassName}
|
||||
type={inputType}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={showSavedPlaceholder ? savedPlaceholder : placeholder}
|
||||
onChange={(event) => {
|
||||
onValueChange(event.target.value);
|
||||
if (!event.target.value) setVisible(false);
|
||||
}}
|
||||
/>
|
||||
{canReveal && (
|
||||
<button
|
||||
type="button"
|
||||
className="password-field-toggle"
|
||||
aria-label={visible ? hideLabel : revealLabel}
|
||||
title={visible ? hideLabel : revealLabel}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
>
|
||||
{visible ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
webui/src/components/PolicyLockedHint.tsx
Normal file
11
webui/src/components/PolicyLockedHint.tsx
Normal file
@@ -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 <p className={`muted small-note policy-locked-hint ${className}`.trim()}>{children}</p>;
|
||||
}
|
||||
66
webui/src/components/PolicySourcePath.tsx
Normal file
66
webui/src/components/PolicySourcePath.tsx
Normal file
@@ -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 (
|
||||
<div className={`policy-source-path ${className}`.trim()} aria-label={label}>
|
||||
<span className="policy-source-path-label">{label}</span>
|
||||
<ol>
|
||||
{cleanItems.map((item, index) => (
|
||||
<li key={`${item.label}-${index}`}>
|
||||
<span>{item.label}</span>
|
||||
{item.appliedFields.length > 0 && <small>{sourceFieldSummary(item.appliedFields)}</small>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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({
|
||||
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(smtp.port)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Username"><input value={stringValue(smtp.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password"><input type="password" value={stringValue(smtp.password)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ password: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField
|
||||
value={stringValue(smtp.password)}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
saved={smtpPasswordSaved}
|
||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => onSmtpChange({ password })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Security"><SecuritySelect value={stringValue(smtp.security, "starttls")} disabled={smtpFieldsDisabled} onChange={(security) => onSmtpChange({ security })} /></FormField>
|
||||
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
</div>
|
||||
@@ -174,7 +192,16 @@ export default function MailServerSettingsPanel({
|
||||
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(imap.port)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Username"><input value={stringValue(imap.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password"><input type="password" value={stringValue(imap.password)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ password: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField
|
||||
value={stringValue(imap.password)}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
saved={imapPasswordSaved}
|
||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => onImapChange({ password })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Security"><SecuritySelect value={stringValue(imap.security, "tls")} disabled={imapFieldsDisabled} onChange={(security) => onImapChange({ security })} /></FormField>
|
||||
<FormField label="Detected/saved sent folder"><input value={stringValue(imap.sent_folder, "auto")} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ sent_folder: event.target.value })} /></FormField>
|
||||
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
|
||||
@@ -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({
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" && <FormField label="Initial password"><input type="password" value={draft.password} placeholder="Leave empty to generate" onChange={(event) => setDraft({ ...draft, password: event.target.value })} /></FormField>}
|
||||
{editing === "new" && (
|
||||
<FormField label="Initial password">
|
||||
<PasswordField
|
||||
value={draft.password}
|
||||
placeholder="Leave empty to generate"
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => setDraft({ ...draft, password })}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Account status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||
</div>
|
||||
<div className="admin-assignment-grid">
|
||||
|
||||
@@ -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<void>;
|
||||
}) {
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
|
||||
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
|
||||
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<TenantAdminItem | null>(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<DataGridColumn<TenantAdminItem>[]>(() => [
|
||||
{ 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) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
|
||||
{ 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({
|
||||
</div>
|
||||
<h3>System governance overrides</h3>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} label="Custom tenant groups" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} label="Custom tenant roles" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} label="Tenant API keys" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="Custom tenant groups" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="Custom tenant roles" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="Tenant API keys" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
||||
</div>
|
||||
<p className="muted small-note">Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.</p>
|
||||
{systemDeniedGovernance && <p className="muted small-note">Explicit allow is unavailable for {systemDeniedGovernance} because the current system setting denies it.</p>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Tenant details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
@@ -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 <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">Inherit system setting</option><option value="allow">Allow when system allows</option><option value="deny">Explicitly deny</option></select></FormField>;
|
||||
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: { label: string; value: OverrideValue; onChange: (value: OverrideValue) => void; disabled?: boolean; allowDisabled?: boolean }) {
|
||||
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">Inherit system setting</option><option value="allow" disabled={allowDisabled}>Allow when system allows</option><option value="deny">Explicitly deny</option></select></FormField>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" && <FormField label="Initial password"><input type="password" value={draft.password} placeholder="Leave empty to generate" onChange={(event) => setDraft({ ...draft, password: event.target.value })} /></FormField>}
|
||||
{editing === "new" && (
|
||||
<FormField label="Initial password">
|
||||
<PasswordField
|
||||
value={draft.password}
|
||||
placeholder="Leave empty to generate"
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => setDraft({ ...draft, password })}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Membership status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||
</div>
|
||||
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> Require password change when account settings are implemented</label>}
|
||||
|
||||
@@ -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({
|
||||
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Password">
|
||||
<input type="password" value={password} autoComplete="current-password" onChange={(e) => setPassword(e.target.value)} />
|
||||
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
|
||||
</FormField>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import EffectivePolicyBlock, { EffectivePolicyValue } from "../../components/EffectivePolicyBlock";
|
||||
import type { PolicySourcePathItem } from "../../components/PolicySourcePath";
|
||||
import FormField from "../../components/FormField";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
|
||||
@@ -158,6 +160,7 @@ export function RetentionPolicyEditor({
|
||||
const [policy, setPolicy] = useState<PrivacyRetentionPolicyPatch>({});
|
||||
const [effectivePolicy, setEffectivePolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
||||
const [parentPolicy, setParentPolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
||||
const [effectivePolicySources, setEffectivePolicySources] = useState<PolicySourcePathItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -181,6 +184,7 @@ export function RetentionPolicyEditor({
|
||||
setPolicy({});
|
||||
setEffectivePolicy(null);
|
||||
setParentPolicy(null);
|
||||
setEffectivePolicySources([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -189,10 +193,12 @@ export function RetentionPolicyEditor({
|
||||
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
||||
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
||||
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
||||
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||
} catch (err) {
|
||||
setPolicy({});
|
||||
setEffectivePolicy(null);
|
||||
setParentPolicy(null);
|
||||
setEffectivePolicySources([]);
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -210,6 +216,7 @@ export function RetentionPolicyEditor({
|
||||
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
||||
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
||||
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
||||
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||
setSuccess("Retention policy saved.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
@@ -316,19 +323,21 @@ export function RetentionPolicyEditor({
|
||||
</section>
|
||||
|
||||
{effectivePolicy && (
|
||||
<section className="retention-policy-section retention-policy-effective">
|
||||
<h3>Effective policy</h3>
|
||||
<div className="retention-policy-effective-grid">
|
||||
<PolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
|
||||
<PolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
|
||||
<PolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
|
||||
<PolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
|
||||
<PolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
|
||||
<PolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
|
||||
<PolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
|
||||
{showAllowColumn && <PolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
|
||||
</div>
|
||||
</section>
|
||||
<EffectivePolicyBlock
|
||||
title="Effective policy"
|
||||
sourcePath={effectivePolicySources.length > 0 ? effectivePolicySources : retentionPolicySourcePath(scopeType)}
|
||||
className="retention-policy-section retention-policy-effective"
|
||||
gridClassName="retention-policy-effective-grid"
|
||||
>
|
||||
<EffectivePolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
|
||||
<EffectivePolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
|
||||
<EffectivePolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
|
||||
<EffectivePolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
|
||||
<EffectivePolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
|
||||
<EffectivePolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
|
||||
<EffectivePolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
|
||||
{showAllowColumn && <EffectivePolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
|
||||
</EffectivePolicyBlock>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -399,10 +408,6 @@ function AuditDetailSelect({ value, includeInherit, disabled, onChange }: { valu
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyValue({ label, value }: { label: string; value: string }) {
|
||||
return <div><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
function rawJsonValue(value: boolean | undefined): RawJsonValue {
|
||||
if (value === undefined) return "inherit";
|
||||
return value ? "keep" : "disable";
|
||||
@@ -440,6 +445,14 @@ function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
|
||||
return count === 0 ? "Fully inherited" : `${count} local override${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function retentionPolicySourcePath(scopeType: PrivacyRetentionPolicyScope): string[] {
|
||||
if (scopeType === "system") return ["System"];
|
||||
if (scopeType === "tenant") return ["System", "Tenant"];
|
||||
if (scopeType === "user") return ["System", "Tenant", "User"];
|
||||
if (scopeType === "group") return ["System", "Tenant", "Group"];
|
||||
return ["System", "Tenant", "Owner policy", "Campaign"];
|
||||
}
|
||||
|
||||
function normalizeAllowLimits(value: PrivacyRetentionLimitPermissionPatch | PrivacyRetentionLimitPermissions | null | undefined): PrivacyRetentionLimitPermissions {
|
||||
return { ...defaultAllowLowerLevelLimits, ...(value ?? {}) };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
import Button from "../../components/Button";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
@@ -240,7 +241,11 @@ export default function SettingsPage({
|
||||
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
|
||||
</FormField>
|
||||
<FormField label="Automation API key" help="Used only when there is no browser session token. Browser login remains the preferred interactive mode.">
|
||||
<input type="password" value={settings.apiKey} onChange={(e) => onSettingsChange({ ...settings, apiKey: e.target.value })} />
|
||||
<PasswordField
|
||||
value={settings.apiKey}
|
||||
autoComplete="off"
|
||||
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>
|
||||
|
||||
@@ -17,6 +17,8 @@ export { default as Card } from "./components/Card";
|
||||
export { default as ConfirmDialog } from "./components/ConfirmDialog";
|
||||
export { default as Dialog } from "./components/Dialog";
|
||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
||||
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
||||
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
||||
export { default as FormField } from "./components/FormField";
|
||||
export { default as LoadingFrame } from "./components/LoadingFrame";
|
||||
export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
||||
@@ -26,6 +28,12 @@ export { default as MetricCard } from "./components/MetricCard";
|
||||
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
|
||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||
export { default as PageTitle } from "./components/PageTitle";
|
||||
export { default as PasswordField } from "./components/PasswordField";
|
||||
export type { PasswordFieldProps } from "./components/PasswordField";
|
||||
export { default as PolicySourcePath } from "./components/PolicySourcePath";
|
||||
export type { PolicySourcePathItem, PolicySourcePathProps } from "./components/PolicySourcePath";
|
||||
export { default as PolicyLockedHint } from "./components/PolicyLockedHint";
|
||||
export type { PolicyLockedHintProps } from "./components/PolicyLockedHint";
|
||||
export { default as StatusBadge } from "./components/StatusBadge";
|
||||
export { default as Stepper } from "./components/Stepper";
|
||||
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
||||
@@ -39,6 +47,8 @@ export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQu
|
||||
|
||||
export { default as LoginModal } from "./features/auth/LoginModal";
|
||||
export { default as PublicLandingPage } from "./features/auth/PublicLandingPage";
|
||||
export { RetentionPolicyEditor, RetentionPolicyScopeManager } from "./features/privacy/RetentionPolicyManagement";
|
||||
export type { RetentionPolicyTargetOption } from "./features/privacy/RetentionPolicyManagement";
|
||||
|
||||
export { default as AppShell } from "./layout/AppShell";
|
||||
export { default as BreadcrumbBar } from "./layout/BreadcrumbBar";
|
||||
|
||||
@@ -142,6 +142,96 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.policy-source-path {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.policy-source-path-label {
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.policy-source-path ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.policy-source-path li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
.policy-source-path li small {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.policy-source-path li small::before {
|
||||
content: "(";
|
||||
}
|
||||
.policy-source-path li small::after {
|
||||
content: ")";
|
||||
}
|
||||
.policy-source-path li + li::before {
|
||||
content: ">";
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.password-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.password-field.has-toggle input {
|
||||
padding-right: 42px;
|
||||
}
|
||||
.password-field.is-saved-empty input::placeholder {
|
||||
color: var(--muted);
|
||||
opacity: 1;
|
||||
}
|
||||
.password-field-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.password-field-toggle:hover {
|
||||
background: rgba(0, 0, 0, .06);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.password-field-toggle:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--blue) 55%, #fff);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Mail-style address editor: textarea surface + pills + add dialog. */
|
||||
.email-address-editor {
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user