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.
|
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
|
## 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
|
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
|
## 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.
|
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-files
|
||||||
-e ../govoplan-mail
|
-e ../govoplan-mail
|
||||||
-e ../govoplan-campaign
|
-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,
|
PrivacyPolicyError,
|
||||||
apply_retention_policy,
|
apply_retention_policy,
|
||||||
effective_privacy_policy,
|
effective_privacy_policy,
|
||||||
|
effective_privacy_policy_sources,
|
||||||
get_privacy_policy_for_scope,
|
get_privacy_policy_for_scope,
|
||||||
parent_privacy_policy,
|
parent_privacy_policy,
|
||||||
|
parent_privacy_policy_sources,
|
||||||
privacy_policy_from_settings,
|
privacy_policy_from_settings,
|
||||||
set_privacy_policy,
|
set_privacy_policy,
|
||||||
set_privacy_policy_for_scope,
|
set_privacy_policy_for_scope,
|
||||||
@@ -1235,6 +1237,8 @@ def read_privacy_retention_policy(
|
|||||||
policy=policy,
|
policy=policy,
|
||||||
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")),
|
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")),
|
||||||
parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None,
|
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:
|
except PrivacyPolicyError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from 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,
|
policy=policy,
|
||||||
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")),
|
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")),
|
||||||
parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None,
|
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:
|
except PrivacyPolicyError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
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):
|
def _parent_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
|
||||||
if scope_type == "system":
|
if scope_type == "system":
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -312,6 +312,13 @@ class SystemAccountCreateResponse(BaseModel):
|
|||||||
temporary_password: str | None = None
|
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):
|
class PrivacyRetentionPolicyItem(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -360,6 +367,8 @@ class PrivacyRetentionPolicyScopeResponse(BaseModel):
|
|||||||
policy: dict[str, Any]
|
policy: dict[str, Any]
|
||||||
effective_policy: PrivacyRetentionPolicyItem
|
effective_policy: PrivacyRetentionPolicyItem
|
||||||
parent_policy: PrivacyRetentionPolicyItem | None = None
|
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):
|
class RetentionRunRequest(BaseModel):
|
||||||
|
|||||||
@@ -304,6 +304,93 @@ def parent_privacy_policy(session: Session, *, tenant_id: str, scope_type: str,
|
|||||||
return policy
|
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]:
|
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()
|
clean_scope = scope_type.strip().casefold()
|
||||||
if clean_scope == "system":
|
if clean_scope == "system":
|
||||||
|
|||||||
@@ -205,6 +205,55 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(allowed.status_code, 200, allowed.text)
|
self.assertEqual(allowed.status_code, 200, allowed.text)
|
||||||
self.assertEqual(allowed.json()["user"]["display_name"], "Cookie Admin")
|
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:
|
def test_encrypted_mail_profile_can_back_campaign_without_exposing_secrets(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
created_profile = self.client.post(
|
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(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)
|
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:
|
def test_campaign_scoped_mail_profile_policy_is_enforced(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
created = self.client.post(
|
created = self.client.post(
|
||||||
@@ -2498,6 +2578,78 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(blocked.status_code, 422, blocked.text)
|
self.assertEqual(blocked.status_code, 422, blocked.text)
|
||||||
self.assertIn("blacklist", blocked.json()["detail"])
|
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:
|
def test_allowed_mail_profile_ids_gate_campaign_selection(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
created = self.client.post(
|
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 PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
|
||||||
|
export type PolicySourceStep = {
|
||||||
|
scope_type: string;
|
||||||
|
scope_id?: string | null;
|
||||||
|
label: string;
|
||||||
|
applied_fields?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type PrivacyRetentionPolicyScopeResponse = {
|
export type PrivacyRetentionPolicyScopeResponse = {
|
||||||
scope_type: PrivacyRetentionPolicyScope;
|
scope_type: PrivacyRetentionPolicyScope;
|
||||||
scope_id?: string | null;
|
scope_id?: string | null;
|
||||||
policy: PrivacyRetentionPolicyPatch;
|
policy: PrivacyRetentionPolicyPatch;
|
||||||
effective_policy: PrivacyRetentionPolicy;
|
effective_policy: PrivacyRetentionPolicy;
|
||||||
parent_policy?: PrivacyRetentionPolicy | null;
|
parent_policy?: PrivacyRetentionPolicy | null;
|
||||||
|
effective_policy_sources?: PolicySourceStep[];
|
||||||
|
parent_policy_sources?: PolicySourceStep[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SystemSettingsItem = {
|
export type SystemSettingsItem = {
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ import type { ApiSettings } from "../types";
|
|||||||
const STORAGE_KEY = "multimailer.apiSettings";
|
const STORAGE_KEY = "multimailer.apiSettings";
|
||||||
const SESSION_STORAGE_KEY = "multimailer.session";
|
const SESSION_STORAGE_KEY = "multimailer.session";
|
||||||
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
|
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 {
|
export class ApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
@@ -91,6 +102,63 @@ function isUnsafeMethod(method?: string): boolean {
|
|||||||
return !["GET", "HEAD", "OPTIONS", "TRACE"].includes(normalized);
|
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 {
|
export function authHeaders(settings: ApiSettings): Headers {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
if (settings.accessToken) {
|
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> {
|
export async function apiFetch<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
|
||||||
const headers = new Headers(init?.headers || {});
|
const headers = new Headers(init?.headers || {});
|
||||||
|
const method = (init?.method || "GET").toUpperCase();
|
||||||
|
|
||||||
if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
|
if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
|
||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
@@ -113,27 +182,69 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
const csrf = csrfToken();
|
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);
|
headers.set("X-CSRF-Token", csrf);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(apiUrl(settings, path), { ...init, headers, credentials: init?.credentials ?? "include" });
|
if (isUnsafeMethod(method)) {
|
||||||
|
safeRequestGeneration += 1;
|
||||||
if (!response.ok) {
|
inFlightSafeRequests.clear();
|
||||||
const text = await response.text();
|
recentSafeRequests.clear();
|
||||||
throw new ApiError(response.status, response.statusText, text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
const url = apiUrl(settings, path);
|
||||||
return undefined as T;
|
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();
|
||||||
|
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 (!canReuseSafeRequest(method, init)) {
|
||||||
if (!contentType.includes("application/json")) {
|
return runFetch();
|
||||||
return (await response.text()) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<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 = {
|
const tooltipBaseStyle: CSSProperties = {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
zIndex: 10000,
|
zIndex: 20000,
|
||||||
width: "max-content",
|
width: "max-content",
|
||||||
maxWidth: "min(320px, calc(100vw - 48px))",
|
maxWidth: "min(320px, calc(100vw - 48px))",
|
||||||
border: "1px solid var(--line-dark)",
|
border: "1px solid var(--line-dark)",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
|||||||
import Button from "../Button";
|
import Button from "../Button";
|
||||||
import DismissibleAlert from "../DismissibleAlert";
|
import DismissibleAlert from "../DismissibleAlert";
|
||||||
import FormField from "../FormField";
|
import FormField from "../FormField";
|
||||||
|
import PasswordField from "../PasswordField";
|
||||||
import ToggleSwitch from "../ToggleSwitch";
|
import ToggleSwitch from "../ToggleSwitch";
|
||||||
|
|
||||||
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
||||||
@@ -50,10 +51,14 @@ export type MailServerSettingsPanelProps = {
|
|||||||
onImapEnabledChange?: (enabled: boolean) => void;
|
onImapEnabledChange?: (enabled: boolean) => void;
|
||||||
smtpDisabled?: boolean;
|
smtpDisabled?: boolean;
|
||||||
smtpCredentialDisabled?: boolean;
|
smtpCredentialDisabled?: boolean;
|
||||||
|
smtpPasswordSaved?: boolean;
|
||||||
|
smtpSavedPasswordPlaceholder?: string;
|
||||||
smtpActionDisabled?: boolean;
|
smtpActionDisabled?: boolean;
|
||||||
imapToggleDisabled?: boolean;
|
imapToggleDisabled?: boolean;
|
||||||
imapServerDisabled?: boolean;
|
imapServerDisabled?: boolean;
|
||||||
imapCredentialDisabled?: boolean;
|
imapCredentialDisabled?: boolean;
|
||||||
|
imapPasswordSaved?: boolean;
|
||||||
|
imapSavedPasswordPlaceholder?: string;
|
||||||
imapActionDisabled?: boolean;
|
imapActionDisabled?: boolean;
|
||||||
smtpTitle?: ReactNode;
|
smtpTitle?: ReactNode;
|
||||||
imapTitle?: ReactNode;
|
imapTitle?: ReactNode;
|
||||||
@@ -98,10 +103,14 @@ export default function MailServerSettingsPanel({
|
|||||||
onImapEnabledChange,
|
onImapEnabledChange,
|
||||||
smtpDisabled = false,
|
smtpDisabled = false,
|
||||||
smtpCredentialDisabled = smtpDisabled,
|
smtpCredentialDisabled = smtpDisabled,
|
||||||
|
smtpPasswordSaved = false,
|
||||||
|
smtpSavedPasswordPlaceholder = "••••••••",
|
||||||
smtpActionDisabled = smtpDisabled,
|
smtpActionDisabled = smtpDisabled,
|
||||||
imapToggleDisabled = false,
|
imapToggleDisabled = false,
|
||||||
imapServerDisabled = false,
|
imapServerDisabled = false,
|
||||||
imapCredentialDisabled = imapServerDisabled,
|
imapCredentialDisabled = imapServerDisabled,
|
||||||
|
imapPasswordSaved = false,
|
||||||
|
imapSavedPasswordPlaceholder = "••••••••",
|
||||||
imapActionDisabled = imapServerDisabled,
|
imapActionDisabled = imapServerDisabled,
|
||||||
smtpTitle = "SMTP login",
|
smtpTitle = "SMTP login",
|
||||||
imapTitle = "IMAP sent-folder append",
|
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="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="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="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="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>
|
<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>
|
</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="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="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="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="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="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>
|
<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 DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
import Dialog from "../../components/Dialog";
|
import Dialog from "../../components/Dialog";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
|
import PasswordField from "../../components/PasswordField";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
import {
|
import {
|
||||||
createSystemAccount,
|
createSystemAccount,
|
||||||
@@ -192,7 +193,16 @@ export default function SystemUsersPanel({
|
|||||||
<div className="admin-form-grid two-columns">
|
<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="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>
|
<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>
|
<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>
|
||||||
<div className="admin-assignment-grid">
|
<div className="admin-assignment-grid">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo } from "../../types";
|
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 Button from "../../components/Button";
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
import Dialog from "../../components/Dialog";
|
import Dialog from "../../components/Dialog";
|
||||||
@@ -65,6 +65,7 @@ export default function TenantsPanel({
|
|||||||
onAuthRefresh: () => Promise<void>;
|
onAuthRefresh: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||||
|
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
|
||||||
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
|
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
|
||||||
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
|
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
|
||||||
const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
|
const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
|
||||||
@@ -79,12 +80,14 @@ export default function TenantsPanel({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const [nextTenants, nextOwnerCandidates] = await Promise.all([
|
const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([
|
||||||
fetchTenants(settings),
|
fetchTenants(settings),
|
||||||
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([])
|
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
|
||||||
|
fetchSystemSettings(settings).catch(() => null)
|
||||||
]);
|
]);
|
||||||
setTenants(nextTenants);
|
setTenants(nextTenants);
|
||||||
setOwnerCandidates(nextOwnerCandidates);
|
setOwnerCandidates(nextOwnerCandidates);
|
||||||
|
setSystemSettings(nextSystemSettings);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(adminErrorMessage(err));
|
setError(adminErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -180,6 +183,14 @@ export default function TenantsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeTenantId = (auth.active_tenant ?? auth.tenant).id;
|
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>[]>(() => [
|
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: "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}` },
|
{ 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>
|
</div>
|
||||||
<h3>System governance overrides</h3>
|
<h3>System governance overrides</h3>
|
||||||
<div className="admin-form-grid two-columns">
|
<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} allowDisabled={!systemAllowsCustomGroups} 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} allowDisabled={!systemAllowsCustomRoles} 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={!systemAllowsApiKeys} label="Tenant API keys" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
||||||
</div>
|
</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>
|
<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>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="Tenant details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
<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 }) {
|
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">Allow when system allows</option><option value="deny">Explicitly deny</option></select></FormField>;
|
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 DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||||
import Dialog from "../../components/Dialog";
|
import Dialog from "../../components/Dialog";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
|
import PasswordField from "../../components/PasswordField";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||||
import AdminSelectionList from "./components/AdminSelectionList";
|
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">
|
<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="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>
|
<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>
|
<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>
|
</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>}
|
{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 { login } from "../../api/auth";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
|
import PasswordField from "../../components/PasswordField";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
|
|
||||||
export default function LoginModal({
|
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)} />
|
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Password">
|
<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>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<footer className="modal-footer">
|
<footer className="modal-footer">
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
|
import EffectivePolicyBlock, { EffectivePolicyValue } from "../../components/EffectivePolicyBlock";
|
||||||
|
import type { PolicySourcePathItem } from "../../components/PolicySourcePath";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
|
|
||||||
@@ -158,6 +160,7 @@ export function RetentionPolicyEditor({
|
|||||||
const [policy, setPolicy] = useState<PrivacyRetentionPolicyPatch>({});
|
const [policy, setPolicy] = useState<PrivacyRetentionPolicyPatch>({});
|
||||||
const [effectivePolicy, setEffectivePolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
const [effectivePolicy, setEffectivePolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
||||||
const [parentPolicy, setParentPolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
const [parentPolicy, setParentPolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
||||||
|
const [effectivePolicySources, setEffectivePolicySources] = useState<PolicySourcePathItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -181,6 +184,7 @@ export function RetentionPolicyEditor({
|
|||||||
setPolicy({});
|
setPolicy({});
|
||||||
setEffectivePolicy(null);
|
setEffectivePolicy(null);
|
||||||
setParentPolicy(null);
|
setParentPolicy(null);
|
||||||
|
setEffectivePolicySources([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -189,10 +193,12 @@ export function RetentionPolicyEditor({
|
|||||||
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
||||||
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
||||||
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
||||||
|
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPolicy({});
|
setPolicy({});
|
||||||
setEffectivePolicy(null);
|
setEffectivePolicy(null);
|
||||||
setParentPolicy(null);
|
setParentPolicy(null);
|
||||||
|
setEffectivePolicySources([]);
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -210,6 +216,7 @@ export function RetentionPolicyEditor({
|
|||||||
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
||||||
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
||||||
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
||||||
|
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||||
setSuccess("Retention policy saved.");
|
setSuccess("Retention policy saved.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
@@ -316,19 +323,21 @@ export function RetentionPolicyEditor({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{effectivePolicy && (
|
{effectivePolicy && (
|
||||||
<section className="retention-policy-section retention-policy-effective">
|
<EffectivePolicyBlock
|
||||||
<h3>Effective policy</h3>
|
title="Effective policy"
|
||||||
<div className="retention-policy-effective-grid">
|
sourcePath={effectivePolicySources.length > 0 ? effectivePolicySources : retentionPolicySourcePath(scopeType)}
|
||||||
<PolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
|
className="retention-policy-section retention-policy-effective"
|
||||||
<PolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
|
gridClassName="retention-policy-effective-grid"
|
||||||
<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)} />
|
<EffectivePolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
|
||||||
<PolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
|
<EffectivePolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
|
||||||
<PolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
|
<EffectivePolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
|
||||||
<PolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
|
<EffectivePolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
|
||||||
{showAllowColumn && <PolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
|
<EffectivePolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
|
||||||
</div>
|
<EffectivePolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
|
||||||
</section>
|
<EffectivePolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
|
||||||
|
{showAllowColumn && <EffectivePolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
|
||||||
|
</EffectivePolicyBlock>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 {
|
function rawJsonValue(value: boolean | undefined): RawJsonValue {
|
||||||
if (value === undefined) return "inherit";
|
if (value === undefined) return "inherit";
|
||||||
return value ? "keep" : "disable";
|
return value ? "keep" : "disable";
|
||||||
@@ -440,6 +445,14 @@ function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
|
|||||||
return count === 0 ? "Fully inherited" : `${count} local override${count === 1 ? "" : "s"}`;
|
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 {
|
function normalizeAllowLimits(value: PrivacyRetentionLimitPermissionPatch | PrivacyRetentionLimitPermissions | null | undefined): PrivacyRetentionLimitPermissions {
|
||||||
return { ...defaultAllowLowerLevelLimits, ...(value ?? {}) };
|
return { ...defaultAllowLowerLevelLimits, ...(value ?? {}) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom";
|
|||||||
import type { ApiSettings, AuthInfo } from "../../types";
|
import type { ApiSettings, AuthInfo } from "../../types";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
|
import PasswordField from "../../components/PasswordField";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
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" />
|
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Automation API key" help="Used only when there is no browser session token. Browser login remains the preferred interactive mode.">
|
<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>
|
</FormField>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>
|
<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 ConfirmDialog } from "./components/ConfirmDialog";
|
||||||
export { default as Dialog } from "./components/Dialog";
|
export { default as Dialog } from "./components/Dialog";
|
||||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
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 FormField } from "./components/FormField";
|
||||||
export { default as LoadingFrame } from "./components/LoadingFrame";
|
export { default as LoadingFrame } from "./components/LoadingFrame";
|
||||||
export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
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 { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
|
||||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||||
export { default as PageTitle } from "./components/PageTitle";
|
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 StatusBadge } from "./components/StatusBadge";
|
||||||
export { default as Stepper } from "./components/Stepper";
|
export { default as Stepper } from "./components/Stepper";
|
||||||
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
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 LoginModal } from "./features/auth/LoginModal";
|
||||||
export { default as PublicLandingPage } from "./features/auth/PublicLandingPage";
|
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 AppShell } from "./layout/AppShell";
|
||||||
export { default as BreadcrumbBar } from "./layout/BreadcrumbBar";
|
export { default as BreadcrumbBar } from "./layout/BreadcrumbBar";
|
||||||
|
|||||||
@@ -142,6 +142,96 @@
|
|||||||
font-size: 18px;
|
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. */
|
/* Mail-style address editor: textarea surface + pills + add dialog. */
|
||||||
.email-address-editor {
|
.email-address-editor {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in New Issue
Block a user