Prepare v0.1.0 release dependencies

This commit is contained in:
2026-06-24 20:02:49 +02:00
parent ce30b4d054
commit 9c2b84a64a
27 changed files with 900 additions and 48 deletions

View File

@@ -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

View File

@@ -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.

View 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.

View File

@@ -1,4 +1,4 @@
-r requirements.txt
-e .[server]
-e ../govoplan-files
-e ../govoplan-mail
-e ../govoplan-campaign

6
requirements-release.txt Normal file
View 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

View File

@@ -1 +1 @@
-e .[server]
.[server]

View File

@@ -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

View File

@@ -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):

View File

@@ -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":

View File

@@ -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(

View 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
}
}

View File

@@ -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 = {

View File

@@ -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,27 +182,69 @@ 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 (!response.ok) {
const text = await response.text();
throw new ApiError(response.status, response.statusText, text);
if (isUnsafeMethod(method)) {
safeRequestGeneration += 1;
inFlightSafeRequests.clear();
recentSafeRequests.clear();
}
if (response.status === 204) {
return undefined as T;
const url = apiUrl(settings, path);
const fetchInit = { ...init, headers, credentials: init?.credentials ?? "include" };
async function runFetch(): Promise<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 (!contentType.includes("application/json")) {
return (await response.text()) as T;
if (!canReuseSafeRequest(method, init)) {
return runFetch();
}
return (await response.json()) as T;
const cacheKey = safeRequestKey(url, method, headers, init);
const requestGeneration = safeRequestGeneration;
const recent = recentSafeResponse(cacheKey);
if (recent !== undefined) {
return recent as T;
}
const existing = inFlightSafeRequests.get(cacheKey);
if (existing) {
return existing as Promise<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;
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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);
}

View File

@@ -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)",

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>;
}

View File

@@ -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>}

View File

@@ -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">

View File

@@ -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 ?? {}) };
}

View File

@@ -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>

View File

@@ -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";

View File

@@ -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;