1 Commits

Author SHA1 Message Date
df701fddd2 Release v0.1.3 2026-06-26 01:39:19 +02:00
29 changed files with 600 additions and 154 deletions

View File

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

View File

@@ -46,10 +46,10 @@ fail() {
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PARENT="$(dirname "$ROOT")" PARENT="$(dirname "$ROOT")"
REPOS=( REPOS=(
"$ROOT"
"$PARENT/govoplan-files" "$PARENT/govoplan-files"
"$PARENT/govoplan-mail" "$PARENT/govoplan-mail"
"$PARENT/govoplan-campaign" "$PARENT/govoplan-campaign"
"$ROOT"
) )
REMOTE="origin" REMOTE="origin"

View File

@@ -107,7 +107,7 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
manifest = ModuleManifest( manifest = ModuleManifest(
id="access", id="access",
name="Access", name="Access",
version="1.0.0", version="0.1.2",
permissions=ACCESS_PERMISSIONS, permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES, role_templates=ACCESS_ROLE_TEMPLATES,
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata), migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata),
@@ -116,4 +116,3 @@ manifest = ModuleManifest(
def get_manifest() -> ModuleManifest: def get_manifest() -> ModuleManifest:
return manifest return manifest

View File

@@ -88,6 +88,8 @@ from govoplan_core.api.v1.admin_schemas import (
TenantListResponse, TenantListResponse,
TenantOwnerCandidate, TenantOwnerCandidate,
TenantOwnerCandidateListResponse, TenantOwnerCandidateListResponse,
TenantSettingsItem,
TenantSettingsUpdateRequest,
TenantUpdateRequest, TenantUpdateRequest,
UserAdminItem, UserAdminItem,
UserCreateRequest, UserCreateRequest,
@@ -275,6 +277,16 @@ def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem:
) )
def _tenant_settings_item(tenant: Tenant) -> TenantSettingsItem:
return TenantSettingsItem(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
default_locale=tenant.default_locale,
settings=tenant.settings or {},
)
def _system_account_item(session: Session, account: Account) -> SystemAccountItem: def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
memberships = ( memberships = (
session.query(User, Tenant) session.query(User, Tenant)
@@ -539,6 +551,41 @@ def update_tenant(
return _tenant_item(session, tenant) return _tenant_item(session, tenant)
@router.get("/tenant/settings", response_model=TenantSettingsItem)
def get_tenant_settings(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:settings:read")),
):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return _tenant_settings_item(tenant)
@router.patch("/tenant/settings", response_model=TenantSettingsItem)
def update_tenant_settings(
payload: TenantSettingsUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:settings:write")),
):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
tenant.default_locale = payload.default_locale.strip() or "en"
session.add(tenant)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="tenant.settings.updated",
object_type="tenant",
object_id=tenant.id,
details={"default_locale": tenant.default_locale},
)
session.commit()
return _tenant_settings_item(tenant)
@router.get("/users", response_model=UserListResponse) @router.get("/users", response_model=UserListResponse)
def list_users( def list_users(
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),

View File

@@ -123,6 +123,20 @@ class TenantUpdateRequest(BaseModel):
is_active: bool | None = None is_active: bool | None = None
class TenantSettingsItem(BaseModel):
id: str
slug: str
name: str
default_locale: str = Field(default="en", min_length=1, max_length=20)
settings: dict[str, Any] = Field(default_factory=dict)
class TenantSettingsUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
default_locale: str = Field(min_length=1, max_length=20)
class RoleSummary(BaseModel): class RoleSummary(BaseModel):
id: str id: str
slug: str = Field(min_length=1, max_length=100) slug: str = Field(min_length=1, max_length=100)

View File

@@ -549,6 +549,46 @@ class ApiSmokeTests(unittest.TestCase):
) )
self.assertEqual(invalid_share.status_code, 400, invalid_share.text) self.assertEqual(invalid_share.status_code, 400, invalid_share.text)
campaign = self.client.post(
"/api/v1/campaigns/new",
headers=headers,
json={"external_id": "bulk-file-share", "name": "Bulk file share"},
)
self.assertEqual(campaign.status_code, 200, campaign.text)
campaign_id = campaign.json()["campaign"]["id"]
bulk_share = self.client.post(
"/api/v1/files/bulk-shares",
headers=headers,
json={
"file_ids": [first_file["id"], second_file["id"], first_file["id"]],
"target_type": "campaign",
"target_id": campaign_id,
"permission": "read",
},
)
self.assertEqual(bulk_share.status_code, 200, bulk_share.text)
self.assertEqual(bulk_share.json()["shared_count"], 2)
self.assertEqual(len(bulk_share.json()["shares"]), 2)
repeated_bulk_share = self.client.post(
"/api/v1/files/bulk-shares",
headers=headers,
json={
"file_ids": [first_file["id"], second_file["id"]],
"target_type": "campaign",
"target_id": campaign_id,
"permission": "read",
},
)
self.assertEqual(repeated_bulk_share.status_code, 200, repeated_bulk_share.text)
self.assertEqual(repeated_bulk_share.json()["shared_count"], 2)
campaign_files = self.client.get("/api/v1/files", headers=headers, params={"campaign_id": campaign_id})
self.assertEqual(campaign_files.status_code, 200, campaign_files.text)
self.assertEqual({item["id"] for item in campaign_files.json()["files"]}, {first_file["id"], second_file["id"]})
download = self.client.get(f"/api/v1/files/{first_file['id']}/download", headers=headers) download = self.client.get(f"/api/v1/files/{first_file['id']}/download", headers=headers)
self.assertEqual(download.status_code, 200, download.text) self.assertEqual(download.status_code, 200, download.text)
self.assertIn("filename*=UTF-8''report.txt", download.headers.get("content-disposition", "")) self.assertIn("filename*=UTF-8''report.txt", download.headers.get("content-disposition", ""))
@@ -2187,6 +2227,19 @@ class ApiSmokeTests(unittest.TestCase):
tenant_owner_headers = {"Authorization": f"Bearer {tenant_owner_login.json()['access_token']}"} tenant_owner_headers = {"Authorization": f"Bearer {tenant_owner_login.json()['access_token']}"}
self.assertEqual(self.client.get("/api/v1/admin/users", headers=tenant_owner_headers).status_code, 200) self.assertEqual(self.client.get("/api/v1/admin/users", headers=tenant_owner_headers).status_code, 200)
self.assertEqual(self.client.get("/api/v1/admin/tenants", headers=tenant_owner_headers).status_code, 403) self.assertEqual(self.client.get("/api/v1/admin/tenants", headers=tenant_owner_headers).status_code, 403)
tenant_settings = self.client.get("/api/v1/admin/tenant/settings", headers=tenant_owner_headers)
self.assertEqual(tenant_settings.status_code, 200, tenant_settings.text)
self.assertEqual(tenant_settings.json()["default_locale"], "de")
updated_tenant_settings = self.client.patch(
"/api/v1/admin/tenant/settings",
headers=tenant_owner_headers,
json={"default_locale": "de-AT"},
)
self.assertEqual(updated_tenant_settings.status_code, 200, updated_tenant_settings.text)
self.assertEqual(updated_tenant_settings.json()["default_locale"], "de-AT")
refreshed_tenant_owner = self.client.get("/api/v1/auth/me", headers=tenant_owner_headers)
self.assertEqual(refreshed_tenant_owner.status_code, 200, refreshed_tenant_owner.text)
self.assertEqual(refreshed_tenant_owner.json()["active_tenant"]["default_locale"], "de-AT")
# Global account suspension immediately invalidates all of its tenant # Global account suspension immediately invalidates all of its tenant
# sessions. Reactivation restores access without changing memberships. # sessions. Reactivation restores access without changing memberships.
@@ -2242,6 +2295,7 @@ class ApiSmokeTests(unittest.TestCase):
self.assertIn("user.created", actions) self.assertIn("user.created", actions)
self.assertIn("group.created", actions) self.assertIn("group.created", actions)
self.assertIn("role.created", actions) self.assertIn("role.created", actions)
self.assertIn("tenant.settings.updated", actions)

View File

@@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
import importlib
import os import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import textwrap import textwrap
import tomllib
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -25,6 +27,13 @@ from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_files.backend.storage.backends import LocalFilesystemStorageBackend, StorageBackendError from govoplan_files.backend.storage.backends import LocalFilesystemStorageBackend, StorageBackendError
_MANIFEST_PROJECT_MODULES = {
"access": "govoplan_core",
"files": "govoplan_files",
"mail": "govoplan_mail",
"campaigns": "govoplan_campaign",
}
def _settings(root: Path) -> SimpleNamespace: def _settings(root: Path) -> SimpleNamespace:
return SimpleNamespace( return SimpleNamespace(
@@ -73,12 +82,31 @@ class ModuleSystemTests(unittest.TestCase):
) )
return create_app(config), settings return create_app(config), settings
def _source_project_version(self, package_module: str) -> str:
module = importlib.import_module(package_module)
module_file = getattr(module, "__file__", None)
if not module_file:
self.fail(f"Could not locate source package for {package_module}")
for path in Path(module_file).resolve().parents:
pyproject_path = path / "pyproject.toml"
if pyproject_path.exists():
with pyproject_path.open("rb") as handle:
return str(tomllib.load(handle)["project"]["version"])
self.fail(f"Could not locate pyproject.toml for {package_module}")
def test_discovers_installed_core_and_product_module_manifests(self) -> None: def test_discovers_installed_core_and_product_module_manifests(self) -> None:
manifests = available_module_manifests() manifests = available_module_manifests()
self.assertTrue({"access", "files", "mail", "campaigns"}.issubset(manifests)) self.assertTrue({"access", "files", "mail", "campaigns"}.issubset(manifests))
self.assertEqual(manifests["campaigns"].dependencies, ("access",)) self.assertEqual(manifests["campaigns"].dependencies, ("access",))
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail")) self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail"))
def test_module_manifest_versions_match_source_project_versions(self) -> None:
manifests = available_module_manifests()
for manifest_id, package_module in _MANIFEST_PROJECT_MODULES.items():
with self.subTest(manifest_id=manifest_id):
self.assertIn(manifest_id, manifests)
self.assertEqual(self._source_project_version(package_module), manifests[manifest_id].version)
def test_enabled_module_permutations_register_expected_routes(self) -> None: def test_enabled_module_permutations_register_expected_routes(self) -> None:
cases = ( cases = (
("core_only", (), {"access"}, set()), ("core_only", (), {"access"}, set()),

View File

@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.1", "version": "0.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.1", "version": "0.1.2",
"dependencies": { "dependencies": {
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui", "@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
@@ -32,7 +32,7 @@
}, },
"../../govoplan-campaign/webui": { "../../govoplan-campaign/webui": {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.1", "version": "0.1.2",
"dependencies": { "dependencies": {
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui" "@govoplan/mail-webui": "file:../../govoplan-mail/webui"
@@ -41,7 +41,7 @@
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.2",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -55,9 +55,9 @@
}, },
"../../govoplan-files/webui": { "../../govoplan-files/webui": {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.1", "version": "0.1.2",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.2",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -74,9 +74,9 @@
}, },
"../../govoplan-mail/webui": { "../../govoplan-mail/webui": {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.1", "version": "0.1.2",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.2",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -1,16 +1,16 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.1", "version": "0.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.1", "version": "0.1.2",
"dependencies": { "dependencies": {
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.1", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.2",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.1", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.2",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.1" "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
@@ -710,14 +710,14 @@
} }
}, },
"node_modules/@govoplan/campaign-webui": { "node_modules/@govoplan/campaign-webui": {
"version": "0.1.1", "version": "0.1.2",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#39ad3500e2548e74c38a3c75c3cefa85cbf4a00b", "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#39ad3500e2548e74c38a3c75c3cefa85cbf4a00b",
"dependencies": { "dependencies": {
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.1", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.2",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.1" "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.2"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.2",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -730,10 +730,10 @@
} }
}, },
"node_modules/@govoplan/files-webui": { "node_modules/@govoplan/files-webui": {
"version": "0.1.1", "version": "0.1.2",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#b5f7c43028e7a2b5cc261835b66ec0923cb30970", "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#b5f7c43028e7a2b5cc261835b66ec0923cb30970",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.2",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -749,10 +749,10 @@
} }
}, },
"node_modules/@govoplan/mail-webui": { "node_modules/@govoplan/mail-webui": {
"version": "0.1.1", "version": "0.1.2",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#3cd8c45c42d76265e895466e6ce67ad67221c8df", "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#3cd8c45c42d76265e895466e6ce67ad67221c8df",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.2",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.1", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -22,9 +22,9 @@
"preview": "vite preview --host 127.0.0.1 --port 4173" "preview": "vite preview --host 127.0.0.1 --port 4173"
}, },
"dependencies": { "dependencies": {
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.1", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.2",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.1", "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.2",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.1" "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.2"
}, },
"devDependencies": { "devDependencies": {
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",

View File

@@ -174,6 +174,14 @@ export type SystemSettingsItem = {
settings: Record<string, unknown>; settings: Record<string, unknown>;
}; };
export type TenantSettingsItem = {
id: string;
slug: string;
name: string;
default_locale: string;
settings: Record<string, unknown>;
};
export type RetentionRunResponse = { export type RetentionRunResponse = {
result: { result: {
dry_run: boolean; dry_run: boolean;
@@ -277,6 +285,14 @@ export function updateTenant(settings: ApiSettings, tenantId: string, payload: P
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) }); return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
} }
export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettingsItem> {
return apiFetch(settings, "/api/v1/admin/tenant/settings");
}
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string }): Promise<TenantSettingsItem> {
return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> { export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users"); const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
return response.users; return response.users;

View File

@@ -0,0 +1,113 @@
import { useRef, useState, type CSSProperties, type ReactNode } from "react";
import { UploadCloud } from "lucide-react";
export type FileDropZoneProps = {
accept?: string;
multiple?: boolean;
disabled?: boolean;
busy?: boolean;
progress?: number | null;
label?: ReactNode;
actionLabel?: ReactNode;
busyLabel?: ReactNode;
progressLabel?: ReactNode;
note?: ReactNode;
className?: string;
inputLabel?: string;
onFiles: (files: File[]) => void | Promise<void>;
};
export default function FileDropZone({
accept,
multiple = true,
disabled = false,
busy = false,
progress = null,
label = "Drop files here",
actionLabel = "or click to select files",
busyLabel = "Uploading files",
progressLabel,
note,
className = "",
inputLabel = "Drop files here or click to select files",
onFiles
}: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [dragActive, setDragActive] = useState(false);
const progressValue = typeof progress === "number" && Number.isFinite(progress) ? Math.max(0, Math.min(100, progress)) : null;
const showProgress = busy || progressValue !== null;
const interactionDisabled = disabled || busy;
const zoneClassName = `file-drop-zone ${dragActive ? "is-active" : ""} ${showProgress ? "is-busy" : ""} ${className}`.trim();
const roundedProgress = progressValue === null ? null : Math.round(progressValue);
const progressStyle = progressValue === null ? undefined : { "--file-drop-progress": `${progressValue}%` } as CSSProperties;
async function handleFiles(fileList: FileList | File[]) {
if (interactionDisabled) return;
const files = Array.from(fileList);
if (files.length === 0) return;
try {
await onFiles(files);
} finally {
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<>
<div
className={zoneClassName}
role="button"
tabIndex={interactionDisabled ? -1 : 0}
aria-label={inputLabel}
aria-disabled={interactionDisabled}
aria-busy={showProgress || undefined}
onClick={() => {
if (!interactionDisabled) inputRef.current?.click();
}}
onKeyDown={(event) => {
if ((event.key === "Enter" || event.key === " ") && !interactionDisabled) {
event.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(event) => {
event.preventDefault();
if (!interactionDisabled) setDragActive(true);
}}
onDragLeave={() => setDragActive(false)}
onDrop={(event) => {
event.preventDefault();
setDragActive(false);
if (!interactionDisabled) void handleFiles(event.dataTransfer.files);
}}
>
{showProgress ? (
<span
className={`file-drop-progress ${progressValue === null ? "is-indeterminate" : ""}`.trim()}
role="progressbar"
aria-label="Upload progress"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={roundedProgress ?? undefined}
style={progressStyle}
>
<span>{roundedProgress === null ? "" : `${roundedProgress}%`}</span>
</span>
) : (
<UploadCloud size={28} aria-hidden="true" />
)}
<strong>{showProgress ? busyLabel : label}</strong>
<span>{showProgress ? progressLabel ?? (roundedProgress === null ? "Uploading…" : `${roundedProgress}% uploaded`) : actionLabel}</span>
{note && <span className="muted small-text">{note}</span>}
</div>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
hidden
onChange={(event) => event.target.files && void handleFiles(event.target.files)}
/>
</>
);
}

View File

@@ -115,9 +115,13 @@ export default function MessageDisplayPanel({
<strong><Archive size={15} aria-hidden="true" /> {archive.label}</strong> <strong><Archive size={15} aria-hidden="true" /> {archive.label}</strong>
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span> <span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span>
</div> </div>
{archive.protected && <small><LockKeyhole size={13} aria-hidden="true" /> Password protected</small>} {archive.protected && (
<div className="message-display-attachment-protection">
<small><LockKeyhole size={13} aria-hidden="true" /> Password protected</small>
{archive.protectionNote && <small className="message-display-attachment-protection-note">{formatProtectionNote(archive.protectionNote)}</small>}
</div>
)}
</header> </header>
{archive.protectionNote && <p className="muted small-note">{formatProtectionNote(archive.protectionNote)}</p>}
<div className="message-display-attachment-list"> <div className="message-display-attachment-list">
{archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)} {archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
</div> </div>

View File

@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Button from "./Button"; import Button from "./Button";
import Dialog from "./Dialog";
import DismissibleAlert from "./DismissibleAlert"; import DismissibleAlert from "./DismissibleAlert";
export type UnsavedNavigationAction = () => void; export type UnsavedNavigationAction = () => void;
@@ -139,23 +140,26 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
<UnsavedChangesContext.Provider value={value}> <UnsavedChangesContext.Provider value={value}>
{children} {children}
{pendingAction && registration && ( {pendingAction && registration && (
<div className="overlay-backdrop" role="dialog" aria-modal="true"> <Dialog
<div className="modal-panel unsaved-changes-dialog"> open
<header className="modal-header"> role="alertdialog"
<h2>{registration.title ?? "Unsaved changes"}</h2> title={registration.title ?? "Unsaved changes"}
<button className="modal-close" onClick={() => setPendingAction(null)} disabled={saving}>x</button> className="unsaved-changes-dialog"
</header> footerClassName="unsaved-changes-actions"
<div className="modal-body"> closeOnBackdrop={!saving}
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p> closeDisabled={saving}
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>} onClose={() => setPendingAction(null)}
</div> footer={(
<footer className="modal-footer unsaved-changes-actions"> <>
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button> <Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button> <Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "Saving..." : "Save and leave"}</Button> <Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "Saving..." : "Save and leave"}</Button>
</footer> </>
</div> )}
</div> >
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
</Dialog>
)} )}
</UnsavedChangesContext.Provider> </UnsavedChangesContext.Provider>
); );

View File

@@ -762,7 +762,7 @@ export default function DataGrid<T>({
pageSize={paginationPageSize} pageSize={paginationPageSize}
totalRows={paginationTotal} totalRows={paginationTotal}
pageCount={paginationPageCount} pageCount={paginationPageCount}
pageSizeOptions={pagination.pageSizeOptions ?? [25, 50, 100]} pageSizeOptions={pagination.pageSizeOptions ?? [10, 25, 50, 100]}
disabled={pagination.disabled} disabled={pagination.disabled}
onPageChange={pagination.onPageChange} onPageChange={pagination.onPageChange}
onPageSizeChange={pagination.onPageSizeChange} onPageSizeChange={pagination.onPageSizeChange}

View File

@@ -26,7 +26,7 @@ export default function AdminAuditPanel({
const [items, setItems] = useState<AuditAdminItem[]>([]); const [items, setItems] = useState<AuditAdminItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50); const [pageSize, setPageSize] = useState(10);
const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY); const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY);
const [selected, setSelected] = useState<AuditAdminItem | null>(null); const [selected, setSelected] = useState<AuditAdminItem | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -106,7 +106,7 @@ export default function AdminAuditPanel({
page, page,
pageSize, pageSize,
totalRows: total, totalRows: total,
pageSizeOptions: [25, 50, 100, 250], pageSizeOptions: [10, 25, 50, 100, 250],
disabled: loading, disabled: loading,
onPageChange: setPage, onPageChange: setPage,
onPageSizeChange: (next) => { setPageSize(next); setPage(1); } onPageSizeChange: (next) => { setPageSize(next); setPage(1); }

View File

@@ -43,13 +43,14 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
{hasSystemArea(availableSections) && <Card title="System administration"> {hasSystemArea(availableSections) && <Card title="System administration">
<div className="admin-overview-grid"> <div className="admin-overview-grid">
{availableSections.has("system-settings") && <AreaLink title="Settings" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />} {availableSections.has("system-settings") && <AreaLink title="General" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
{availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />} {availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />}
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
{availableSections.has("system-groups") && <AreaLink title="Central groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
{availableSections.has("system-roles") && <AreaLink title="System roles" text="Instance-wide roles assigned directly to global accounts." onClick={() => onSelect("system-roles")} />} {availableSections.has("system-roles") && <AreaLink title="System roles" text="Instance-wide roles assigned directly to global accounts." onClick={() => onSelect("system-roles")} />}
{availableSections.has("system-role-templates") && <AreaLink title="Tenant roles" text="Centrally governed tenant roles and their availability across tenants." onClick={() => onSelect("system-role-templates")} />} {availableSections.has("system-role-templates") && <AreaLink title="Tenant roles" text="Centrally governed tenant roles and their availability across tenants." onClick={() => onSelect("system-role-templates")} />}
{availableSections.has("system-groups") && <AreaLink title="Groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
{availableSections.has("system-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("system-mail-servers")} />}
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
{availableSections.has("system-audit") && <AreaLink title="Audit" text="System-level administrative history." onClick={() => onSelect("system-audit")} />} {availableSections.has("system-audit") && <AreaLink title="Audit" text="System-level administrative history." onClick={() => onSelect("system-audit")} />}
</div> </div>
</Card>} </Card>}
@@ -64,15 +65,13 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
<Card title="Tenant administration"> <Card title="Tenant administration">
<div className="admin-overview-grid"> <div className="admin-overview-grid">
{availableSections.has("tenant-settings") && <AreaLink title="Settings" text="Tenant-specific settings and governance overrides." onClick={() => onSelect("tenant-settings")} />} {availableSections.has("tenant-settings") && <AreaLink title="General" text="Tenant locale and tenant-specific settings." onClick={() => onSelect("tenant-settings")} />}
{availableSections.has("tenant-users") && <AreaLink title="Users" text="Membership status, groups and direct roles in the active tenant." onClick={() => onSelect("tenant-users")} />}
{availableSections.has("tenant-groups") && <AreaLink title="Groups" text="Tenant memberships and inherited roles." onClick={() => onSelect("tenant-groups")} />}
{availableSections.has("tenant-roles") && <AreaLink title="Roles" text="Tenant permission bundles and system-managed role copies." onClick={() => onSelect("tenant-roles")} />} {availableSections.has("tenant-roles") && <AreaLink title="Roles" text="Tenant permission bundles and system-managed role copies." onClick={() => onSelect("tenant-roles")} />}
{availableSections.has("tenant-api-keys") && <AreaLink title="API keys" text="Scoped automation credentials capped by owner permissions." onClick={() => onSelect("tenant-api-keys")} />} {availableSections.has("tenant-groups") && <AreaLink title="Groups" text="Tenant memberships and inherited roles." onClick={() => onSelect("tenant-groups")} />}
{availableSections.has("tenant-users") && <AreaLink title="Users" text="Membership status, groups and direct roles in the active tenant." onClick={() => onSelect("tenant-users")} />}
{availableSections.has("tenant-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("tenant-mail-servers")} />} {availableSections.has("tenant-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("tenant-mail-servers")} />}
{availableSections.has("tenant-retention") && <AreaLink title="Retention" text="Tenant-level privacy retention limits inherited by owned objects." onClick={() => onSelect("tenant-retention")} />} {availableSections.has("tenant-retention") && <AreaLink title="Retention" text="Tenant-level privacy retention limits inherited by owned objects." onClick={() => onSelect("tenant-retention")} />}
{availableSections.has("tenant-user-retention") && <AreaLink title="User retention" text="Retention limits for user-owned campaigns." onClick={() => onSelect("tenant-user-retention")} />} {availableSections.has("tenant-api-keys") && <AreaLink title="API keys" text="Scoped automation credentials capped by owner permissions." onClick={() => onSelect("tenant-api-keys")} />}
{availableSections.has("tenant-group-retention") && <AreaLink title="Group retention" text="Retention limits for group-owned campaigns." onClick={() => onSelect("tenant-group-retention")} />}
{availableSections.has("tenant-audit") && <AreaLink title="Audit" text="Tenant-level administrative history only." onClick={() => onSelect("tenant-audit")} />} {availableSections.has("tenant-audit") && <AreaLink title="Audit" text="Tenant-level administrative history only." onClick={() => onSelect("tenant-audit")} />}
</div> </div>
</Card> </Card>
@@ -82,7 +81,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
} }
function hasSystemArea(sections: ReadonlySet<string>): boolean { function hasSystemArea(sections: ReadonlySet<string>): boolean {
return ["system-settings", "system-retention", "system-tenants", "system-users", "system-groups", "system-roles", "system-role-templates", "system-audit"].some((section) => sections.has(section)); return ["system-settings", "system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-mail-servers", "system-retention", "system-audit"].some((section) => sections.has(section));
} }
function Metric({ title, value, text }: { title: string; value: string | number; text: string }) { function Metric({ title, value, text }: { title: string; value: string | number; text: string }) {

View File

@@ -8,6 +8,7 @@ import { adminReadScopes, hasAnyScope, hasScope } from "../../utils/permissions"
import AdminOverviewPanel from "./AdminOverviewPanel"; import AdminOverviewPanel from "./AdminOverviewPanel";
import SystemUsersPanel from "./SystemUsersPanel"; import SystemUsersPanel from "./SystemUsersPanel";
import SystemSettingsPanel from "./SystemSettingsPanel"; import SystemSettingsPanel from "./SystemSettingsPanel";
import TenantSettingsPanel from "./TenantSettingsPanel";
import GovernanceTemplatesPanel from "./GovernanceTemplatesPanel"; import GovernanceTemplatesPanel from "./GovernanceTemplatesPanel";
import SystemRolesPanel from "./SystemRolesPanel"; import SystemRolesPanel from "./SystemRolesPanel";
import TenantsPanel from "./TenantsPanel"; import TenantsPanel from "./TenantsPanel";
@@ -18,7 +19,6 @@ import ApiKeysPanel from "./ApiKeysPanel";
import AdminAuditPanel from "./AdminAuditPanel"; import AdminAuditPanel from "./AdminAuditPanel";
import MailProfilesPanel from "./MailProfilesPanel"; import MailProfilesPanel from "./MailProfilesPanel";
import RetentionPoliciesPanel from "./RetentionPoliciesPanel"; import RetentionPoliciesPanel from "./RetentionPoliciesPanel";
import PageTitle from "../../components/PageTitle";
import { usePlatformUiCapability } from "../../platform/ModuleContext"; import { usePlatformUiCapability } from "../../platform/ModuleContext";
type AdminSection = type AdminSection =
@@ -120,42 +120,42 @@ export default function AdminPage({
{ {
title: "SYSTEM", title: "SYSTEM",
items: [ items: [
...(available.has("system-settings") ? [{ id: "system-settings" as const, label: "Settings" }] : []), ...(available.has("system-settings") ? [{ id: "system-settings" as const, label: "General" }] : []),
...(available.has("system-retention") ? [{ id: "system-retention" as const, label: "Retention" }] : []),
...(available.has("system-mail-servers") ? [{ id: "system-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("system-tenants") ? [{ id: "system-tenants" as const, label: "Tenants" }] : []), ...(available.has("system-tenants") ? [{ id: "system-tenants" as const, label: "Tenants" }] : []),
...(available.has("system-users") ? [{ id: "system-users" as const, label: "Users" }] : []),
...(available.has("system-groups") ? [{ id: "system-groups" as const, label: "Groups" }] : []),
...(available.has("system-roles") ? [{ id: "system-roles" as const, label: "System roles" }] : []), ...(available.has("system-roles") ? [{ id: "system-roles" as const, label: "System roles" }] : []),
...(available.has("system-role-templates") ? [{ id: "system-role-templates" as const, label: "Tenant roles" }] : []), ...(available.has("system-role-templates") ? [{ id: "system-role-templates" as const, label: "Tenant roles" }] : []),
...(available.has("system-groups") ? [{ id: "system-groups" as const, label: "Groups" }] : []),
...(available.has("system-users") ? [{ id: "system-users" as const, label: "Users" }] : []),
...(available.has("system-mail-servers") ? [{ id: "system-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("system-retention") ? [{ id: "system-retention" as const, label: "Retention" }] : []),
...(available.has("system-audit") ? [{ id: "system-audit" as const, label: "Audit" }] : []) ...(available.has("system-audit") ? [{ id: "system-audit" as const, label: "Audit" }] : [])
] ]
}, },
{ {
title: "TENANT", title: "TENANT",
items: [ items: [
...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "Settings" }] : []), ...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "General" }] : []),
...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []),
...(available.has("tenant-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []),
...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []), ...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []),
...(available.has("tenant-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []), ...(available.has("tenant-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []),
...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []),
...(available.has("tenant-mail-servers") ? [{ id: "tenant-mail-servers" as const, label: "Mail servers" }] : []), ...(available.has("tenant-mail-servers") ? [{ id: "tenant-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("tenant-retention") ? [{ id: "tenant-retention" as const, label: "Retention" }] : []), ...(available.has("tenant-retention") ? [{ id: "tenant-retention" as const, label: "Retention" }] : []),
...(available.has("tenant-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []),
...(available.has("tenant-audit") ? [{ id: "tenant-audit" as const, label: "Audit" }] : []) ...(available.has("tenant-audit") ? [{ id: "tenant-audit" as const, label: "Audit" }] : [])
] ]
}, },
{
title: "USER",
items: [
...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "User mail" }] : []),
...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "User retention" }] : []),
]
},
{ {
title: "GROUP", title: "GROUP",
items: [ items: [
...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Group mail" }] : []), ...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Group retention" }] : []), ...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Retention" }] : []),
]
},
{
title: "USER",
items: [
...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "Retention" }] : []),
] ]
} }
].filter((group) => group.items.length > 0); ].filter((group) => group.items.length > 0);
@@ -197,14 +197,10 @@ export default function AdminPage({
{active === "tenant-retention" && <RetentionPoliciesPanel settings={settings} scopeType="tenant" canWrite={hasScope(auth, "admin:policies:write")} />} {active === "tenant-retention" && <RetentionPoliciesPanel settings={settings} scopeType="tenant" canWrite={hasScope(auth, "admin:policies:write")} />}
{active === "tenant-user-retention" && <RetentionPoliciesPanel settings={settings} scopeType="user" canWrite={hasScope(auth, "admin:policies:write")} />} {active === "tenant-user-retention" && <RetentionPoliciesPanel settings={settings} scopeType="user" canWrite={hasScope(auth, "admin:policies:write")} />}
{active === "tenant-group-retention" && <RetentionPoliciesPanel settings={settings} scopeType="group" canWrite={hasScope(auth, "admin:policies:write")} />} {active === "tenant-group-retention" && <RetentionPoliciesPanel settings={settings} scopeType="group" canWrite={hasScope(auth, "admin:policies:write")} />}
{active === "tenant-settings" && <PreparationPage title="Tenant settings" text="Tenant-specific policy values are now represented by typed governance overrides on the system Tenants page; further campaign-policy inheritance will build on that foundation." />} {active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
{active === "tenant-audit" && <AdminAuditPanel settings={settings} auth={auth} />} {active === "tenant-audit" && <AdminAuditPanel settings={settings} auth={auth} />}
</div> </div>
</section> </section>
</div> </div>
); );
} }
function PreparationPage({ title, text }: { title: string; text: string }) {
return <div className="admin-section-page"><div className="page-heading workspace-heading"><div><PageTitle>{title}</PageTitle><p>{text}</p></div></div><Card><p className="muted">This boundary is intentionally visible but not presented as an implemented editor.</p></Card></div>;
}

View File

@@ -75,7 +75,7 @@ export default function SystemSettingsPanel({ settings, canWrite }: { settings:
return ( return (
<AdminPageLayout <AdminPageLayout
title="System settings" title="System general settings"
description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section." description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section."
loading={loading} loading={loading}
error={error} error={error}

View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import { fetchTenantSettings, updateTenantSettings, type TenantSettingsItem } from "../../api/admin";
import AdminPageLayout from "./components/AdminPageLayout";
import { adminErrorMessage } from "./adminUtils";
const fallback: TenantSettingsItem = {
id: "",
slug: "",
name: "",
default_locale: "en",
settings: {}
};
export default function TenantSettingsPanel({
settings,
canWrite,
onAuthRefresh
}: {
settings: ApiSettings;
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) {
const [draft, setDraft] = useState<TenantSettingsItem>(fallback);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function load() {
setLoading(true);
setError("");
setSuccess("");
try {
setDraft(await fetchTenantSettings(settings));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
async function save() {
setBusy(true);
setError("");
setSuccess("");
try {
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale });
setDraft(saved);
setSuccess("Tenant general settings saved.");
await onAuthRefresh();
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
return (
<AdminPageLayout
title="Tenant general settings"
description="Settings for the active tenant context."
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "Saving..." : "Save general settings"}</Button></>}
>
<div className="admin-settings-form">
<Card title="Locale">
<FormField label="Tenant locale" help="Used as this tenant's locale default for tenant-aware views and future formatting defaults.">
<input value={draft.default_locale} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} />
</FormField>
<dl className="detail-list">
<div><dt>Tenant</dt><dd>{draft.name || "-"}</dd></div>
<div><dt>Slug</dt><dd>{draft.slug || "-"}</dd></div>
</dl>
</Card>
</div>
</AdminPageLayout>
);
}

View File

@@ -1,7 +1,8 @@
import { useState } from "react"; import { useId, useState } from "react";
import type { ApiSettings, LoginResponse } from "../../types"; 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 Dialog from "../../components/Dialog";
import FormField from "../../components/FormField"; import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField"; import PasswordField from "../../components/PasswordField";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
@@ -23,6 +24,7 @@ export default function LoginModal({
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const formId = useId();
async function submit(event: React.FormEvent) { async function submit(event: React.FormEvent) {
event.preventDefault(); event.preventDefault();
@@ -40,13 +42,18 @@ export default function LoginModal({
} }
return ( return (
<div className="overlay-backdrop" role="dialog" aria-modal="true"> <Dialog
<form className="modal-panel" onSubmit={submit}> open
<header className="modal-header"> title={title}
<h2>{title}</h2> onClose={onClose}
<button className="modal-close" type="button" onClick={onClose}>×</button> footer={(
</header> <>
<div className="modal-body form-grid"> <Button type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" form={formId} variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
</>
)}
>
<form id={formId} className="form-grid" onSubmit={submit}>
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>} {message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormField label="Email"> <FormField label="Email">
@@ -55,12 +62,7 @@ export default function LoginModal({
<FormField label="Password"> <FormField label="Password">
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} /> <PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
</FormField> </FormField>
</div>
<footer className="modal-footer">
<Button type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
</footer>
</form> </form>
</div> </Dialog>
); );
} }

View File

@@ -21,6 +21,8 @@ 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 { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock"; export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
export { default as FileDropZone } from "./components/FileDropZone";
export type { FileDropZoneProps } from "./components/FileDropZone";
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";

View File

@@ -1,7 +1,11 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react"; import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
import packageInfo from "../../package.json";
import Button from "../components/Button"; import Button from "../components/Button";
import Dialog from "../components/Dialog";
import { usePlatformModules } from "../platform/ModuleContext";
import type { PlatformWebModule } from "../types";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext"; import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
export default function HelpMenu() { export default function HelpMenu() {
@@ -11,6 +15,7 @@ export default function HelpMenu() {
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
const location = useLocation(); const location = useLocation();
const helpContext = helpContextForPathname(location.pathname); const helpContext = helpContextForPathname(location.pathname);
const modules = usePlatformModules();
useEffect(() => { useEffect(() => {
function openContextHelp(event: KeyboardEvent) { function openContextHelp(event: KeyboardEvent) {
@@ -69,21 +74,20 @@ export default function HelpMenu() {
</div> </div>
)} )}
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />} {contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />} {aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
</div> </div>
); );
} }
function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose: () => void }) { function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose: () => void }) {
return ( return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" data-help-context={context.id}> <Dialog
<div className="modal-panel"> open
<header className="modal-header"> title="Context help"
<h2>Context help</h2> onClose={onClose}
<button className="modal-close" onClick={onClose}>×</button> footer={<Button variant="primary" onClick={onClose}>Close</Button>}
</header> >
<div className="modal-body"> <div className="help-panel-section" data-help-context={context.id}>
<div className="help-panel-section">
<h3>{context.title}</h3> <h3>{context.title}</h3>
<p className="mono-small">Help context: {context.id}</p> <p className="mono-small">Help context: {context.id}</p>
<p className="muted">This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter <span className="kbd">{helpQueryForContext(context)}</span>, to open the right page or section.</p> <p className="muted">This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter <span className="kbd">{helpQueryForContext(context)}</span>, to open the right page or section.</p>
@@ -92,31 +96,28 @@ function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose:
<h3>Next actions</h3> <h3>Next actions</h3>
<p className="muted">The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.</p> <p className="muted">The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.</p>
</div> </div>
</div> </Dialog>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
); );
} }
function AboutModal({ onClose }: { onClose: () => void }) { function AboutModal({ modules, onClose }: { modules: PlatformWebModule[]; onClose: () => void }) {
const moduleSummary = modules.length
? modules.map((module) => `${module.label} v${module.version}`).join(", ")
: "Core shell only";
return ( return (
<div className="overlay-backdrop" role="dialog" aria-modal="true"> <Dialog
<div className="modal-panel"> open
<header className="modal-header"> title="About GovOPlaN"
<h2>About MultiMailer</h2> onClose={onClose}
<button className="modal-close" onClick={onClose}>×</button> footer={<Button variant="primary" onClick={onClose}>Close</Button>}
</header> >
<div className="modal-body">
<div className="about-logo" /> <div className="about-logo" />
<p><strong>MultiMailer WebUI</strong></p> <p><strong>GovOPlaN WebUI</strong></p>
<p className="muted">Version 0.1.0 early development build.</p> <p className="muted">Core WebUI version {packageInfo.version}</p>
<p>MultiMailer is a local-first / server-assisted campaign mailer for structured, personalized bulk messages with attachment resolution, review workflows and auditable sending.</p> <p>GovOPlaN is a modular platform runner for governed workflows, tenant-aware administration, managed files, mail settings and campaign execution.</p>
<p className="muted">Installed modules: {moduleSummary}</p>
<p><a href="https://add-ideas.de" target="_blank" rel="noreferrer">add-ideas.de</a></p> <p><a href="https://add-ideas.de" target="_blank" rel="noreferrer">add-ideas.de</a></p>
<p className="muted">License: project license pending / to be finalized. Backend components are currently development prototypes.</p> </Dialog>
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
); );
} }

View File

@@ -13,7 +13,9 @@
} }
.public-content { .public-content {
min-height: calc(100vh - 64px); min-height: 0;
overflow: auto;
height: 100%;
background: background:
radial-gradient(circle at 18% 18%, rgba(239, 107, 58, .13), transparent 24rem), radial-gradient(circle at 18% 18%, rgba(239, 107, 58, .13), transparent 24rem),
radial-gradient(circle at 78% 14%, rgba(126, 166, 197, .15), transparent 22rem), radial-gradient(circle at 78% 14%, rgba(126, 166, 197, .15), transparent 22rem),

View File

@@ -1092,6 +1092,16 @@
gap: 5px; gap: 5px;
} }
.message-display-attachment-protection {
justify-items: end;
text-align: right;
}
.message-display-attachment-protection-note {
display: block;
max-width: 240px;
overflow-wrap: anywhere;
}
.message-display-headers summary { .message-display-headers summary {
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 700;
@@ -1264,3 +1274,70 @@
.unsaved-changes-actions { .unsaved-changes-actions {
justify-content: flex-end; justify-content: flex-end;
} }
.file-drop-zone {
display: grid;
place-items: center;
gap: 8px;
min-height: 170px;
border: 1px dashed var(--line-dark);
border-radius: 8px;
background: var(--panel-soft);
color: var(--muted);
text-align: center;
cursor: pointer;
transition: border-color .16s ease, background .16s ease, box-shadow .16s ease;
}
.file-drop-zone:hover,
.file-drop-zone:focus-visible,
.file-drop-zone.is-active {
border-color: #0d6efd;
background: rgba(13, 110, 253, .08);
}
.file-drop-zone:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(13, 110, 253, .18);
}
.file-drop-zone[aria-disabled="true"] {
cursor: not-allowed;
opacity: .65;
}
.file-drop-zone.is-busy {
border-color: #0d6efd;
background: rgba(13, 110, 253, .08);
cursor: progress;
opacity: 1;
}
.file-drop-progress {
--file-drop-progress: 0%;
position: relative;
display: grid;
place-items: center;
width: 58px;
height: 58px;
border-radius: 50%;
background: conic-gradient(#0d6efd var(--file-drop-progress), rgba(13, 110, 253, .16) 0);
color: var(--text-strong);
font-size: 12px;
font-weight: 800;
line-height: 1;
}
.file-drop-progress::before {
content: "";
position: absolute;
inset: 6px;
border-radius: 50%;
background: var(--panel-soft);
box-shadow: inset 0 0 0 1px rgba(13, 110, 253, .12);
}
.file-drop-progress > span {
position: relative;
z-index: 1;
}
.file-drop-progress.is-indeterminate {
background: conic-gradient(#0d6efd 0 32%, rgba(13, 110, 253, .16) 32% 100%);
animation: file-drop-progress-spin .85s linear infinite;
}
@keyframes file-drop-progress-spin {
to { transform: rotate(360deg); }
}

View File

@@ -95,7 +95,7 @@
gap: 1rem; gap: 1rem;
padding: 14px 18px; padding: 14px 18px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: var(--bar, #f5f4f1); background: var(--panel, #f7f6f4);
} }
.dialog-title { .dialog-title {
@@ -146,7 +146,7 @@
gap: 0.6rem; gap: 0.6rem;
padding: 12px 18px; padding: 12px 18px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
background: var(--bar, #f5f4f1); background: var(--panel, #f7f6f4);
} }
.confirm-dialog { .confirm-dialog {

View File

@@ -1,5 +1,5 @@
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 58px 1fr; } .app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: 58px 1fr; overflow: hidden; }
.icon-rail { background: var(--rail-bg); color: #c7c6c0; display: flex; flex-direction: column; align-items: center; min-height: 100vh; box-shadow: inset -1px 0 rgba(0,0,0,.35); z-index: 1000; } .icon-rail { background: var(--rail-bg); color: #c7c6c0; display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: inset -1px 0 rgba(0,0,0,.35); z-index: 1000; }
.brand-mark { width: 34px; height: 34px; margin: 15px 0 14px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); color: transparent; font-size: 0; position: relative; } .brand-mark { width: 34px; height: 34px; margin: 15px 0 14px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); color: transparent; font-size: 0; position: relative; }
.brand-mark::after { position: absolute; .brand-mark::after { position: absolute;
top: 9px; top: 9px;
@@ -13,7 +13,7 @@
.icon-nav { width: 100%; display: flex; flex-direction: column; } .icon-nav { width: 100%; display: flex; flex-direction: column; }
.icon-nav-item { height: 52px; display: grid; place-items: center; color: #a7a49f; border-left: 3px solid transparent; text-decoration: none; } .icon-nav-item { height: 52px; display: grid; place-items: center; color: #a7a49f; border-left: 3px solid transparent; text-decoration: none; }
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: #fff; border-left-color: var(--accent); } .icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: #fff; border-left-color: var(--accent); }
.app-main { min-width: 0; display: grid; grid-template-rows: 64px 51px 1fr; min-height: 100vh; } .app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
.titlebar { background: #fbfbfa; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: 0px 0px 10px 0px darkgrey; } .titlebar { background: #fbfbfa; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: 0px 0px 10px 0px darkgrey; }
.tenant-selector { height: 40px; min-width: 210px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: #fff; display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(0,0,0,.05); } .tenant-selector { height: 40px; min-width: 210px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: #fff; display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(0,0,0,.05); }
.tenant-label, .tenant-caret, .muted { color: var(--muted); } .tenant-label, .tenant-caret, .muted { color: var(--muted); }
@@ -27,7 +27,8 @@
.crumb { display: inline-flex; align-items: center; gap: 4px; } .crumb { display: inline-flex; align-items: center; gap: 4px; }
.breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; } .breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; }
.ghost-button { border: 0; background: transparent; color: #77736d; font-weight: 700; } .ghost-button { border: 0; background: transparent; color: #77736d; font-weight: 700; }
.workspace { height: calc(100vh - 112px); display: grid; grid-template-columns: 198px 1fr; } .app-content { min-height: 0; overflow: hidden; }
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
.section-sidebar { background: #c8c4bf; border-right: 1px solid var(--line-dark); padding: 18px 0; border-left: 3px solid #afada9; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 80; overflow-y: scroll; } .section-sidebar { background: #c8c4bf; border-right: 1px solid var(--line-dark); padding: 18px 0; border-left: 3px solid #afada9; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 80; overflow-y: scroll; }
.section-title { font-size: 12px; font-weight: 800; color: #7f7b75; padding: 0 22px 14px; letter-spacing: .06em; } .section-title { font-size: 12px; font-weight: 800; color: #7f7b75; padding: 0 22px 14px; letter-spacing: .06em; }
.section-title-lower { margin-top: 28px; } .section-title-lower { margin-top: 28px; }
@@ -35,13 +36,13 @@
.section-link:hover, .section-link.active { background: rgba(255,255,255,.35); color: #3e3e3f; } .section-link:hover, .section-link.active { background: rgba(255,255,255,.35); color: #3e3e3f; }
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; } .section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
.section-link.subtle { font-size: 13px; } .section-link.subtle { font-size: 13px; }
.workspace-content { min-width: 0; max-width: 100%; overflow: auto; } .workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; }
.content-pad { min-width: 0; max-width: 100%; box-sizing: border-box; padding: 28px 34px; } .content-pad { min-width: 0; max-width: 100%; box-sizing: border-box; padding: 28px 34px; }
.page-heading { margin-bottom: 22px; } .page-heading { margin-bottom: 22px; }
.page-heading h1 { margin: 0; font-size: 26px; color: var(--text-strong); font-weight: 600; } .page-heading h1 { margin: 0; font-size: 26px; color: var(--text-strong); font-weight: 600; }
.page-heading p { margin: 6px 0 0; color: var(--muted); } .page-heading p { margin: 6px 0 0; color: var(--muted); }
.page-heading.split { display: flex; align-items: center; justify-content: space-between; } .page-heading.split { display: flex; align-items: center; justify-content: space-between; }
.panel, .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: scroll; } .panel, .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.card-header { min-height: 56px; padding: 0 24px; border-bottom: 1px solid var(--line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } .card-header { min-height: 56px; padding: 0 24px; border-bottom: 1px solid var(--line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); } .card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;} .card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}

View File

@@ -46,5 +46,6 @@
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; font-family: var(--font); color: var(--text); background: var(--bg); } html, body, #root { height: 100%; }
body { margin: 0; overflow: hidden; font-family: var(--font); color: var(--text); background: var(--bg); }
a { color: inherit; } a { color: inherit; }