Release v0.1.3

This commit is contained in:
2026-06-26 01:39:19 +02:00
parent 02564047e9
commit 7b3fb77208
29 changed files with 600 additions and 154 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -174,6 +174,14 @@ export type SystemSettingsItem = {
settings: Record<string, unknown>;
};
export type TenantSettingsItem = {
id: string;
slug: string;
name: string;
default_locale: string;
settings: Record<string, unknown>;
};
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<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[]> {
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/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>
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span>
</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>
{archive.protectionNote && <p className="muted small-note">{formatProtectionNote(archive.protectionNote)}</p>}
<div className="message-display-attachment-list">
{archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
</div>

View File

@@ -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 }) {
<UnsavedChangesContext.Provider value={value}>
{children}
{pendingAction && registration && (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<div className="modal-panel unsaved-changes-dialog">
<header className="modal-header">
<h2>{registration.title ?? "Unsaved changes"}</h2>
<button className="modal-close" onClick={() => setPendingAction(null)} disabled={saving}>x</button>
</header>
<div className="modal-body">
<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>}
</div>
<footer className="modal-footer unsaved-changes-actions">
<Dialog
open
role="alertdialog"
title={registration.title ?? "Unsaved changes"}
className="unsaved-changes-dialog"
footerClassName="unsaved-changes-actions"
closeOnBackdrop={!saving}
closeDisabled={saving}
onClose={() => setPendingAction(null)}
footer={(
<>
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</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>
);

View File

@@ -762,7 +762,7 @@ export default function DataGrid<T>({
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}

View File

@@ -26,7 +26,7 @@ export default function AdminAuditPanel({
const [items, setItems] = useState<AuditAdminItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [pageSize, setPageSize] = useState(10);
const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY);
const [selected, setSelected] = useState<AuditAdminItem | null>(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); }

View File

@@ -43,13 +43,14 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
{hasSystemArea(availableSections) && <Card title="System administration">
<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-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
{availableSections.has("system-settings") && <AreaLink title="General" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
{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-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")} />}
</div>
</Card>}
@@ -64,15 +65,13 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
<Card title="Tenant administration">
<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-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-settings") && <AreaLink title="General" text="Tenant locale and tenant-specific settings." onClick={() => onSelect("tenant-settings")} />}
{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-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-group-retention") && <AreaLink title="Group retention" text="Retention limits for group-owned campaigns." onClick={() => onSelect("tenant-group-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-audit") && <AreaLink title="Audit" text="Tenant-level administrative history only." onClick={() => onSelect("tenant-audit")} />}
</div>
</Card>
@@ -82,7 +81,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
}
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 }) {

View File

@@ -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" && <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-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} />}
</div>
</section>
</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 (
<AdminPageLayout
title="System settings"
title="System general settings"
description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section."
loading={loading}
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 { 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 (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<form className="modal-panel" onSubmit={submit}>
<header className="modal-header">
<h2>{title}</h2>
<button className="modal-close" type="button" onClick={onClose}>×</button>
</header>
<div className="modal-body form-grid">
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormField label="Email">
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
</FormField>
<FormField label="Password">
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
</FormField>
</div>
<footer className="modal-footer">
<Dialog
open
title={title}
onClose={onClose}
footer={(
<>
<Button type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
</footer>
<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>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormField label="Email">
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
</FormField>
<FormField label="Password">
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
</FormField>
</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 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";

View File

@@ -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<HTMLDivElement>(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() {
</div>
)}
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
</div>
);
}
function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose: () => void }) {
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" data-help-context={context.id}>
<div className="modal-panel">
<header className="modal-header">
<h2>Context help</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<div className="help-panel-section">
<h3>{context.title}</h3>
<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>
</div>
<div className="help-panel-section">
<h3>Next actions</h3>
<p className="muted">The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.</p>
</div>
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
<Dialog
open
title="Context help"
onClose={onClose}
footer={<Button variant="primary" onClick={onClose}>Close</Button>}
>
<div className="help-panel-section" data-help-context={context.id}>
<h3>{context.title}</h3>
<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>
</div>
</div>
<div className="help-panel-section">
<h3>Next actions</h3>
<p className="muted">The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.</p>
</div>
</Dialog>
);
}
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 (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<div className="modal-panel">
<header className="modal-header">
<h2>About MultiMailer</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<div className="about-logo" />
<p><strong>MultiMailer WebUI</strong></p>
<p className="muted">Version 0.1.0 early development build.</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><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>
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
<Dialog
open
title="About GovOPlaN"
onClose={onClose}
footer={<Button variant="primary" onClick={onClose}>Close</Button>}
>
<div className="about-logo" />
<p><strong>GovOPlaN WebUI</strong></p>
<p className="muted">Core WebUI version {packageInfo.version}</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>
</Dialog>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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