diff --git a/pyproject.toml b/pyproject.toml index 0e088e7..3bb73a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.2" +version = "0.1.3" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/push-release-tag.sh b/scripts/push-release-tag.sh index 30d0ad0..da07762 100644 --- a/scripts/push-release-tag.sh +++ b/scripts/push-release-tag.sh @@ -46,10 +46,10 @@ fail() { ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PARENT="$(dirname "$ROOT")" REPOS=( - "$ROOT" "$PARENT/govoplan-files" "$PARENT/govoplan-mail" "$PARENT/govoplan-campaign" + "$ROOT" ) REMOTE="origin" diff --git a/src/govoplan_core/access/manifest.py b/src/govoplan_core/access/manifest.py index 37c5ae7..b6a8b7d 100644 --- a/src/govoplan_core/access/manifest.py +++ b/src/govoplan_core/access/manifest.py @@ -107,7 +107,7 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = ( manifest = ModuleManifest( id="access", name="Access", - version="1.0.0", + version="0.1.2", permissions=ACCESS_PERMISSIONS, role_templates=ACCESS_ROLE_TEMPLATES, migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata), @@ -116,4 +116,3 @@ manifest = ModuleManifest( def get_manifest() -> ModuleManifest: return manifest - diff --git a/src/govoplan_core/api/v1/admin.py b/src/govoplan_core/api/v1/admin.py index 1eacd99..0768186 100644 --- a/src/govoplan_core/api/v1/admin.py +++ b/src/govoplan_core/api/v1/admin.py @@ -88,6 +88,8 @@ from govoplan_core.api.v1.admin_schemas import ( TenantListResponse, TenantOwnerCandidate, TenantOwnerCandidateListResponse, + TenantSettingsItem, + TenantSettingsUpdateRequest, TenantUpdateRequest, UserAdminItem, 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: memberships = ( session.query(User, Tenant) @@ -539,6 +551,41 @@ def update_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) def list_users( tenant_id: str | None = Query(default=None), diff --git a/src/govoplan_core/api/v1/admin_schemas.py b/src/govoplan_core/api/v1/admin_schemas.py index 80b0936..c122838 100644 --- a/src/govoplan_core/api/v1/admin_schemas.py +++ b/src/govoplan_core/api/v1/admin_schemas.py @@ -123,6 +123,20 @@ class TenantUpdateRequest(BaseModel): 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): id: str slug: str = Field(min_length=1, max_length=100) diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index c103e52..695d059 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -549,6 +549,46 @@ class ApiSmokeTests(unittest.TestCase): ) 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) self.assertEqual(download.status_code, 200, download.text) 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']}"} 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) + 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 # sessions. Reactivation restores access without changing memberships. @@ -2242,6 +2295,7 @@ class ApiSmokeTests(unittest.TestCase): self.assertIn("user.created", actions) self.assertIn("group.created", actions) self.assertIn("role.created", actions) + self.assertIn("tenant.settings.updated", actions) diff --git a/tests/test_module_system.py b/tests/test_module_system.py index 63f064c..5688aeb 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -1,11 +1,13 @@ from __future__ import annotations +import importlib import os import shutil import subprocess import sys import tempfile import textwrap +import tomllib import unittest from pathlib import Path 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_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: return SimpleNamespace( @@ -73,12 +82,31 @@ class ModuleSystemTests(unittest.TestCase): ) 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: manifests = available_module_manifests() self.assertTrue({"access", "files", "mail", "campaigns"}.issubset(manifests)) self.assertEqual(manifests["campaigns"].dependencies, ("access",)) 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: cases = ( ("core_only", (), {"access"}, set()), diff --git a/webui/package-lock.json b/webui/package-lock.json index 29ed9c9..231be50 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@govoplan/campaign-webui": "file:../../govoplan-campaign/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui", @@ -32,7 +32,7 @@ }, "../../govoplan-campaign/webui": { "name": "@govoplan/campaign-webui", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/mail-webui": "file:../../govoplan-mail/webui" @@ -41,7 +41,7 @@ "typescript": "^5.7.2" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.1", + "@govoplan/core-webui": "^0.1.2", "lucide-react": "^0.555.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -55,9 +55,9 @@ }, "../../govoplan-files/webui": { "name": "@govoplan/files-webui", - "version": "0.1.1", + "version": "0.1.2", "peerDependencies": { - "@govoplan/core-webui": "^0.1.1", + "@govoplan/core-webui": "^0.1.2", "@vitejs/plugin-react": "^4.3.4", "lucide-react": "^0.555.0", "react": "^19.0.0", @@ -74,9 +74,9 @@ }, "../../govoplan-mail/webui": { "name": "@govoplan/mail-webui", - "version": "0.1.1", + "version": "0.1.2", "peerDependencies": { - "@govoplan/core-webui": "^0.1.1", + "@govoplan/core-webui": "^0.1.2", "lucide-react": "^0.555.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/webui/package-lock.release.json b/webui/package-lock.release.json index 7d3c224..1f1e8a7 100644 --- a/webui/package-lock.release.json +++ b/webui/package-lock.release.json @@ -1,16 +1,16 @@ { "name": "@govoplan/core-webui", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { - "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.1", - "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.1", - "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.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.2", + "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.2" }, "devDependencies": { "@types/react": "^19.0.2", @@ -710,14 +710,14 @@ } }, "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", "dependencies": { - "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.1", - "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.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.2" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.1", + "@govoplan/core-webui": "^0.1.2", "lucide-react": "^0.555.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -730,10 +730,10 @@ } }, "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", "peerDependencies": { - "@govoplan/core-webui": "^0.1.1", + "@govoplan/core-webui": "^0.1.2", "@vitejs/plugin-react": "^4.3.4", "lucide-react": "^0.555.0", "react": "^19.0.0", @@ -749,10 +749,10 @@ } }, "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", "peerDependencies": { - "@govoplan/core-webui": "^0.1.1", + "@govoplan/core-webui": "^0.1.2", "lucide-react": "^0.555.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/webui/package.json b/webui/package.json index baebcc1..ec136d3 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.1", + "version": "0.1.2", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/package.release.json b/webui/package.release.json index 75355a0..a4592eb 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.1", + "version": "0.1.2", "private": true, "type": "module", "main": "src/index.ts", @@ -22,9 +22,9 @@ "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.1", - "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.1", - "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.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.2", + "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.2" }, "devDependencies": { "lucide-react": "^0.555.0", diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index e44f021..739ab5c 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -174,6 +174,14 @@ export type SystemSettingsItem = { settings: Record; }; +export type TenantSettingsItem = { + id: string; + slug: string; + name: string; + default_locale: string; + settings: Record; +}; + export type RetentionRunResponse = { result: { 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) }); } +export function fetchTenantSettings(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/tenant/settings"); +} + +export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string }): Promise { + return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) }); +} + export async function fetchUsers(settings: ApiSettings): Promise { const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users"); return response.users; diff --git a/webui/src/components/FileDropZone.tsx b/webui/src/components/FileDropZone.tsx new file mode 100644 index 0000000..4b403da --- /dev/null +++ b/webui/src/components/FileDropZone.tsx @@ -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; +}; + +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(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 ( + <> +
{ + 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 ? ( + + {roundedProgress === null ? "" : `${roundedProgress}%`} + + ) : ( +
+ event.target.files && void handleFiles(event.target.files)} + /> + + ); +} diff --git a/webui/src/components/MessageDisplayPanel.tsx b/webui/src/components/MessageDisplayPanel.tsx index 63cedd6..5dd7b82 100644 --- a/webui/src/components/MessageDisplayPanel.tsx +++ b/webui/src/components/MessageDisplayPanel.tsx @@ -115,9 +115,13 @@ export default function MessageDisplayPanel({ {archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP - {archive.protected && } + {archive.protected && ( +
+ + {archive.protectionNote && {formatProtectionNote(archive.protectionNote)}} +
+ )} - {archive.protectionNote &&

{formatProtectionNote(archive.protectionNote)}

}
{archive.items.map((attachment, index) => )}
diff --git a/webui/src/components/UnsavedChangesGuard.tsx b/webui/src/components/UnsavedChangesGuard.tsx index 922022f..0c80d9c 100644 --- a/webui/src/components/UnsavedChangesGuard.tsx +++ b/webui/src/components/UnsavedChangesGuard.tsx @@ -1,6 +1,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import Button from "./Button"; +import Dialog from "./Dialog"; import DismissibleAlert from "./DismissibleAlert"; export type UnsavedNavigationAction = () => void; @@ -139,23 +140,26 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) { {children} {pendingAction && registration && ( -
-
-
-

{registration.title ?? "Unsaved changes"}

- -
-
-

{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}

- {saveError && {saveError}} -
-
+ setPendingAction(null)} + footer={( + <> -
-
-
+ + )} + > +

{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}

+ {saveError && {saveError}} + )}
); diff --git a/webui/src/components/table/DataGrid.tsx b/webui/src/components/table/DataGrid.tsx index 636bb0c..50c2b4f 100644 --- a/webui/src/components/table/DataGrid.tsx +++ b/webui/src/components/table/DataGrid.tsx @@ -762,7 +762,7 @@ export default function DataGrid({ pageSize={paginationPageSize} totalRows={paginationTotal} pageCount={paginationPageCount} - pageSizeOptions={pagination.pageSizeOptions ?? [25, 50, 100]} + pageSizeOptions={pagination.pageSizeOptions ?? [10, 25, 50, 100]} disabled={pagination.disabled} onPageChange={pagination.onPageChange} onPageSizeChange={pagination.onPageSizeChange} diff --git a/webui/src/features/admin/AdminAuditPanel.tsx b/webui/src/features/admin/AdminAuditPanel.tsx index 4b76b4c..2775d43 100644 --- a/webui/src/features/admin/AdminAuditPanel.tsx +++ b/webui/src/features/admin/AdminAuditPanel.tsx @@ -26,7 +26,7 @@ export default function AdminAuditPanel({ const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(50); + const [pageSize, setPageSize] = useState(10); const [query, setQuery] = useState(DEFAULT_QUERY); const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); @@ -106,7 +106,7 @@ export default function AdminAuditPanel({ page, pageSize, totalRows: total, - pageSizeOptions: [25, 50, 100, 250], + pageSizeOptions: [10, 25, 50, 100, 250], disabled: loading, onPageChange: setPage, onPageSizeChange: (next) => { setPageSize(next); setPage(1); } diff --git a/webui/src/features/admin/AdminOverviewPanel.tsx b/webui/src/features/admin/AdminOverviewPanel.tsx index 2e003f6..b735b84 100644 --- a/webui/src/features/admin/AdminOverviewPanel.tsx +++ b/webui/src/features/admin/AdminOverviewPanel.tsx @@ -43,13 +43,14 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio {hasSystemArea(availableSections) &&
- {availableSections.has("system-settings") && onSelect("system-settings")} />} - {availableSections.has("system-retention") && onSelect("system-retention")} />} + {availableSections.has("system-settings") && onSelect("system-settings")} />} {availableSections.has("system-tenants") && onSelect("system-tenants")} />} - {availableSections.has("system-users") && onSelect("system-users")} />} - {availableSections.has("system-groups") && onSelect("system-groups")} />} {availableSections.has("system-roles") && onSelect("system-roles")} />} {availableSections.has("system-role-templates") && onSelect("system-role-templates")} />} + {availableSections.has("system-groups") && onSelect("system-groups")} />} + {availableSections.has("system-users") && onSelect("system-users")} />} + {availableSections.has("system-mail-servers") && onSelect("system-mail-servers")} />} + {availableSections.has("system-retention") && onSelect("system-retention")} />} {availableSections.has("system-audit") && onSelect("system-audit")} />}
} @@ -64,15 +65,13 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
- {availableSections.has("tenant-settings") && onSelect("tenant-settings")} />} - {availableSections.has("tenant-users") && onSelect("tenant-users")} />} - {availableSections.has("tenant-groups") && onSelect("tenant-groups")} />} + {availableSections.has("tenant-settings") && onSelect("tenant-settings")} />} {availableSections.has("tenant-roles") && onSelect("tenant-roles")} />} - {availableSections.has("tenant-api-keys") && onSelect("tenant-api-keys")} />} + {availableSections.has("tenant-groups") && onSelect("tenant-groups")} />} + {availableSections.has("tenant-users") && onSelect("tenant-users")} />} {availableSections.has("tenant-mail-servers") && onSelect("tenant-mail-servers")} />} {availableSections.has("tenant-retention") && onSelect("tenant-retention")} />} - {availableSections.has("tenant-user-retention") && onSelect("tenant-user-retention")} />} - {availableSections.has("tenant-group-retention") && onSelect("tenant-group-retention")} />} + {availableSections.has("tenant-api-keys") && onSelect("tenant-api-keys")} />} {availableSections.has("tenant-audit") && onSelect("tenant-audit")} />}
@@ -82,7 +81,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio } function hasSystemArea(sections: ReadonlySet): 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 }) { diff --git a/webui/src/features/admin/AdminPage.tsx b/webui/src/features/admin/AdminPage.tsx index e8460c9..9267b84 100644 --- a/webui/src/features/admin/AdminPage.tsx +++ b/webui/src/features/admin/AdminPage.tsx @@ -8,6 +8,7 @@ import { adminReadScopes, hasAnyScope, hasScope } from "../../utils/permissions" import AdminOverviewPanel from "./AdminOverviewPanel"; import SystemUsersPanel from "./SystemUsersPanel"; import SystemSettingsPanel from "./SystemSettingsPanel"; +import TenantSettingsPanel from "./TenantSettingsPanel"; import GovernanceTemplatesPanel from "./GovernanceTemplatesPanel"; import SystemRolesPanel from "./SystemRolesPanel"; import TenantsPanel from "./TenantsPanel"; @@ -18,7 +19,6 @@ import ApiKeysPanel from "./ApiKeysPanel"; import AdminAuditPanel from "./AdminAuditPanel"; import MailProfilesPanel from "./MailProfilesPanel"; import RetentionPoliciesPanel from "./RetentionPoliciesPanel"; -import PageTitle from "../../components/PageTitle"; import { usePlatformUiCapability } from "../../platform/ModuleContext"; type AdminSection = @@ -120,42 +120,42 @@ export default function AdminPage({ { title: "SYSTEM", items: [ - ...(available.has("system-settings") ? [{ id: "system-settings" as const, label: "Settings" }] : []), - ...(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-settings") ? [{ id: "system-settings" as const, label: "General" }] : []), ...(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-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" }] : []) ] }, { title: "TENANT", items: [ - ...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "Settings" }] : []), - ...(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-settings") ? [{ id: "tenant-settings" as const, label: "General" }] : []), ...(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-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" }] : []) ] }, - { - 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", items: [ - ...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Group mail" }] : []), - ...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Group retention" }] : []), + ...(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: "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); @@ -197,14 +197,10 @@ export default function AdminPage({ {active === "tenant-retention" && } {active === "tenant-user-retention" && } {active === "tenant-group-retention" && } - {active === "tenant-settings" && } + {active === "tenant-settings" && } {active === "tenant-audit" && } ); } - -function PreparationPage({ title, text }: { title: string; text: string }) { - return
{title}

{text}

This boundary is intentionally visible but not presented as an implemented editor.

; -} diff --git a/webui/src/features/admin/SystemSettingsPanel.tsx b/webui/src/features/admin/SystemSettingsPanel.tsx index 122a562..37c6161 100644 --- a/webui/src/features/admin/SystemSettingsPanel.tsx +++ b/webui/src/features/admin/SystemSettingsPanel.tsx @@ -75,7 +75,7 @@ export default function SystemSettingsPanel({ settings, canWrite }: { settings: return ( Promise; +}) { + const [draft, setDraft] = useState(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 ( + } + > +
+ + + setDraft({ ...draft, default_locale: event.target.value })} /> + +
+
Tenant
{draft.name || "-"}
+
Slug
{draft.slug || "-"}
+
+
+
+
+ ); +} diff --git a/webui/src/features/auth/LoginModal.tsx b/webui/src/features/auth/LoginModal.tsx index a96e673..442d5bc 100644 --- a/webui/src/features/auth/LoginModal.tsx +++ b/webui/src/features/auth/LoginModal.tsx @@ -1,7 +1,8 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import type { ApiSettings, LoginResponse } from "../../types"; import { login } from "../../api/auth"; import Button from "../../components/Button"; +import Dialog from "../../components/Dialog"; import FormField from "../../components/FormField"; import PasswordField from "../../components/PasswordField"; import DismissibleAlert from "../../components/DismissibleAlert"; @@ -23,6 +24,7 @@ export default function LoginModal({ const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); + const formId = useId(); async function submit(event: React.FormEvent) { event.preventDefault(); @@ -40,27 +42,27 @@ export default function LoginModal({ } return ( -
-
-
-

{title}

- -
-
- {message && {message}} - {error && {error}} - - setEmail(e.target.value)} /> - - - - -
-
+ - -
+ + + )} + > + + {message && {message}} + {error && {error}} + + setEmail(e.target.value)} /> + + + +
-
+ ); } diff --git a/webui/src/index.ts b/webui/src/index.ts index ef0f09e..be74fca 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -21,6 +21,8 @@ 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 FileDropZone } from "./components/FileDropZone"; +export type { FileDropZoneProps } from "./components/FileDropZone"; export { default as FormField } from "./components/FormField"; export { default as LoadingFrame } from "./components/LoadingFrame"; export { default as LoadingIndicator } from "./components/LoadingIndicator"; diff --git a/webui/src/layout/HelpMenu.tsx b/webui/src/layout/HelpMenu.tsx index d260646..41c8f6a 100644 --- a/webui/src/layout/HelpMenu.tsx +++ b/webui/src/layout/HelpMenu.tsx @@ -1,7 +1,11 @@ import { useEffect, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react"; +import packageInfo from "../../package.json"; 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"; export default function HelpMenu() { @@ -11,6 +15,7 @@ export default function HelpMenu() { const wrapRef = useRef(null); const location = useLocation(); const helpContext = helpContextForPathname(location.pathname); + const modules = usePlatformModules(); useEffect(() => { function openContextHelp(event: KeyboardEvent) { @@ -69,54 +74,50 @@ export default function HelpMenu() { )} {contextOpen && setContextOpen(false)} />} - {aboutOpen && setAboutOpen(false)} />} + {aboutOpen && setAboutOpen(false)} />} ); } function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose: () => void }) { return ( -
-
-
-

Context help

- -
-
-
-

{context.title}

-

Help context: {context.id}

-

This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter {helpQueryForContext(context)}, to open the right page or section.

-
-
-

Next actions

-

The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.

-
-
-
+ Close} + > +
+

{context.title}

+

Help context: {context.id}

+

This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter {helpQueryForContext(context)}, to open the right page or section.

-
+
+

Next actions

+

The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.

+
+ ); } -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 ( -
-
-
-

About MultiMailer

- -
-
-
-

MultiMailer WebUI

-

Version 0.1.0 — early development build.

-

MultiMailer is a local-first / server-assisted campaign mailer for structured, personalized bulk messages with attachment resolution, review workflows and auditable sending.

-

add-ideas.de

-

License: project license pending / to be finalized. Backend components are currently development prototypes.

-
-
-
-
+ Close} + > +
+

GovOPlaN WebUI

+

Core WebUI version {packageInfo.version}

+

GovOPlaN is a modular platform runner for governed workflows, tenant-aware administration, managed files, mail settings and campaign execution.

+

Installed modules: {moduleSummary}

+

add-ideas.de

+
); } diff --git a/webui/src/styles/auth-gate.css b/webui/src/styles/auth-gate.css index 460c399..1c3c8e6 100644 --- a/webui/src/styles/auth-gate.css +++ b/webui/src/styles/auth-gate.css @@ -13,7 +13,9 @@ } .public-content { - min-height: calc(100vh - 64px); + min-height: 0; + overflow: auto; + height: 100%; background: 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), diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index 3f500d1..85b2aa2 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -1092,6 +1092,16 @@ 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 { cursor: pointer; font-weight: 700; @@ -1264,3 +1274,70 @@ .unsaved-changes-actions { 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); } +} diff --git a/webui/src/styles/dialogs.css b/webui/src/styles/dialogs.css index 94f57c9..c61035b 100644 --- a/webui/src/styles/dialogs.css +++ b/webui/src/styles/dialogs.css @@ -95,7 +95,7 @@ gap: 1rem; padding: 14px 18px; border-bottom: 1px solid var(--line); - background: var(--bar, #f5f4f1); + background: var(--panel, #f7f6f4); } .dialog-title { @@ -146,7 +146,7 @@ gap: 0.6rem; padding: 12px 18px; border-top: 1px solid var(--line); - background: var(--bar, #f5f4f1); + background: var(--panel, #f7f6f4); } .confirm-dialog { diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css index c9e8050..94dd0e8 100644 --- a/webui/src/styles/layout.css +++ b/webui/src/styles/layout.css @@ -1,5 +1,5 @@ -.app-shell { min-height: 100vh; display: grid; grid-template-columns: 58px 1fr; } -.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; } +.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; 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::after { position: absolute; top: 9px; @@ -13,7 +13,7 @@ .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: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; } .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); } @@ -27,7 +27,8 @@ .crumb { display: inline-flex; align-items: center; gap: 4px; } .breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; } .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-title { font-size: 12px; font-weight: 800; color: #7f7b75; padding: 0 22px 14px; letter-spacing: .06em; } .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.active { border-left: 3px solid var(--accent); font-weight: 700; } .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; } .page-heading { margin-bottom: 22px; } .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.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 h2 { margin: 0; font-size: 16px; color: var(--text-strong); } .card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;} diff --git a/webui/src/styles/tokens.css b/webui/src/styles/tokens.css index 6e22134..72a45f9 100644 --- a/webui/src/styles/tokens.css +++ b/webui/src/styles/tokens.css @@ -46,5 +46,6 @@ } * { 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; }