chore: consolidate platform split checks
@@ -3,6 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#25282a" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#ef6b3a" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>GovOPlaN</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
93
webui/package-lock.json
generated
@@ -12,14 +12,17 @@
|
||||
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
@@ -28,7 +31,7 @@
|
||||
"vite": "^6.0.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
@@ -42,7 +45,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
@@ -61,7 +64,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
@@ -78,7 +81,7 @@
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
@@ -102,7 +105,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
@@ -113,13 +116,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-dashboard/webui": {
|
||||
"name": "@govoplan/dashboard-webui",
|
||||
"version": "0.1.6",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-docs/webui": {
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.6",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-files/webui": {
|
||||
"name": "@govoplan/files-webui",
|
||||
"version": "0.1.6",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
@@ -140,7 +178,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
@@ -151,6 +189,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-ops/webui": {
|
||||
"name": "@govoplan/ops-webui",
|
||||
"version": "0.1.6",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -891,6 +948,14 @@
|
||||
"resolved": "../../govoplan-campaign/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/dashboard-webui": {
|
||||
"resolved": "../../govoplan-dashboard/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/docs-webui": {
|
||||
"resolved": "../../govoplan-docs/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/files-webui": {
|
||||
"resolved": "../../govoplan-files/webui",
|
||||
"link": true
|
||||
@@ -899,6 +964,10 @@
|
||||
"resolved": "../../govoplan-mail/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/ops-webui": {
|
||||
"resolved": "../../govoplan-ops/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1722,9 +1791,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.555.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
|
||||
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz",
|
||||
"integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js",
|
||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
|
||||
},
|
||||
@@ -29,14 +30,17 @@
|
||||
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
@@ -45,7 +49,7 @@
|
||||
"vite": "^6.0.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
|
||||
@@ -25,9 +25,12 @@
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.6",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.6",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.6",
|
||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.6",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.6",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.6",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.6"
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.6",
|
||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"lucide-react": "^0.555.0",
|
||||
|
||||
11
webui/public/app-icon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="GovOPlaN">
|
||||
<rect width="512" height="512" rx="96" fill="#25282a"/>
|
||||
<g transform="rotate(-90 256 256)" fill="none" stroke-width="116" stroke-linecap="butt">
|
||||
<circle cx="256" cy="256" r="150" stroke="#ef6b3a" stroke-dasharray="188.5 754.0" stroke-dashoffset="0"/>
|
||||
<circle cx="256" cy="256" r="150" stroke="#f2c66d" stroke-dasharray="188.5 754.0" stroke-dashoffset="-188.5"/>
|
||||
<circle cx="256" cy="256" r="150" stroke="#80b9b0" stroke-dasharray="188.5 754.0" stroke-dashoffset="-377.0"/>
|
||||
<circle cx="256" cy="256" r="150" stroke="#7e9fc0" stroke-dasharray="188.5 754.0" stroke-dashoffset="-565.5"/>
|
||||
<circle cx="256" cy="256" r="150" stroke="#56545f" stroke-dasharray="188.5 754.0" stroke-dashoffset="-754.0"/>
|
||||
</g>
|
||||
<circle cx="256" cy="256" r="91" fill="#25282a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 881 B |
BIN
webui/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
webui/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 663 B |
BIN
webui/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
webui/public/favicon-48x48.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
webui/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
webui/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
10
webui/public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="GovOPlaN">
|
||||
<g transform="rotate(-90 256 256)" fill="none" stroke-width="132" stroke-linecap="butt">
|
||||
<circle cx="256" cy="256" r="170" stroke="#ef6b3a" stroke-dasharray="213.63 854.51" stroke-dashoffset="0"/>
|
||||
<circle cx="256" cy="256" r="170" stroke="#f2c66d" stroke-dasharray="213.63 854.51" stroke-dashoffset="-213.63"/>
|
||||
<circle cx="256" cy="256" r="170" stroke="#80b9b0" stroke-dasharray="213.63 854.51" stroke-dashoffset="-427.26"/>
|
||||
<circle cx="256" cy="256" r="170" stroke="#7e9fc0" stroke-dasharray="213.63 854.51" stroke-dashoffset="-640.88"/>
|
||||
<circle cx="256" cy="256" r="170" stroke="#56545f" stroke-dasharray="213.63 854.51" stroke-dashoffset="-854.51"/>
|
||||
</g>
|
||||
<circle cx="256" cy="256" r="104" fill="#25282a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 838 B |
11
webui/public/maskable-icon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="GovOPlaN">
|
||||
<rect width="512" height="512" fill="#25282a"/>
|
||||
<g transform="rotate(-90 256 256)" fill="none" stroke-width="92" stroke-linecap="butt">
|
||||
<circle cx="256" cy="256" r="119" stroke="#ef6b3a" stroke-dasharray="149.54 598.16" stroke-dashoffset="0"/>
|
||||
<circle cx="256" cy="256" r="119" stroke="#f2c66d" stroke-dasharray="149.54 598.16" stroke-dashoffset="-149.54"/>
|
||||
<circle cx="256" cy="256" r="119" stroke="#80b9b0" stroke-dasharray="149.54 598.16" stroke-dashoffset="-299.08"/>
|
||||
<circle cx="256" cy="256" r="119" stroke="#7e9fc0" stroke-dasharray="149.54 598.16" stroke-dashoffset="-448.62"/>
|
||||
<circle cx="256" cy="256" r="119" stroke="#56545f" stroke-dasharray="149.54 598.16" stroke-dashoffset="-598.16"/>
|
||||
</g>
|
||||
<circle cx="256" cy="256" r="72" fill="#25282a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 886 B |
3
webui/public/safari-pinned-tab.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path fill="#000000" fill-rule="evenodd" d="M256 20a236 236 0 1 1 0 472 236 236 0 0 1 0-472Zm0 132a104 104 0 1 0 0 208 104 104 0 0 0 0-208Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 215 B |
30
webui/public/site.webmanifest
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "GovOPlaN",
|
||||
"short_name": "GovOPlaN",
|
||||
"description": "GovOPlaN administrative operations platform",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#25282a",
|
||||
"theme_color": "#25282a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
webui/public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
webui/public/web-app-manifest-512x512-maskable.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
webui/public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
347
webui/scripts/audit-i18n-structural.mjs
Normal file
@@ -0,0 +1,347 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vm from "node:vm";
|
||||
import ts from "typescript";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webuiDir = path.resolve(scriptDir, "..");
|
||||
const workspaceRoot = path.resolve(webuiDir, "..", "..");
|
||||
|
||||
const sourceRoots = fs.readdirSync(workspaceRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith("govoplan-"))
|
||||
.map((entry) => path.join(workspaceRoot, entry.name, "webui", "src"))
|
||||
.filter((sourceRoot) => fs.existsSync(sourceRoot));
|
||||
|
||||
const generatedCatalogs = sourceRoots
|
||||
.map((sourceRoot) => path.join(sourceRoot, "i18n", "generatedTranslations.ts"))
|
||||
.filter((file) => fs.existsSync(file));
|
||||
|
||||
function scanStructuralSourcePositions(roots) {
|
||||
const files = roots.flatMap((root) => rgFiles(root).filter((file) => /\.(tsx?|jsx?)$/.test(file) && !file.endsWith("/i18n/generatedTranslations.ts")));
|
||||
const findings = [];
|
||||
for (const file of files) {
|
||||
const source = ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
||||
|
||||
function report(node, label) {
|
||||
const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
|
||||
findings.push(`${file}:${line + 1}:${character + 1} ${label} contains an i18n marker`);
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (isStringLiteralLike(node) && node.text.includes("i18n:") && isStructuralStringPosition(node, file) && !isAllowedStructuralStringPosition(node, file)) {
|
||||
report(node, "structural string");
|
||||
}
|
||||
if (ts.isJsxAttribute(node)) {
|
||||
const name = nodeName(node.name);
|
||||
if ((isStructuralName(name) || isStructuralJsxValue(node)) && hasI18nLiteral(node.initializer)) report(node, `JSX ${name}`);
|
||||
}
|
||||
if (ts.isPropertyAssignment(node)) {
|
||||
const name = nodeName(node.name);
|
||||
if ((isStructuralName(name) || isAlgorithmNameProperty(node) || isListOptionValueProperty(node)) && hasI18nLiteral(node.initializer)) report(node, `object ${name}`);
|
||||
if (hasI18nLiteral(node.name) && !isAllowedStructuralStringPosition(node.name, file)) report(node, "object key");
|
||||
}
|
||||
if (ts.isShorthandPropertyAssignment(node) && node.name.text.includes("i18n:")) report(node, "object key");
|
||||
if (ts.isVariableDeclaration(node)) {
|
||||
const name = nodeName(node.name);
|
||||
if (isStructuralVariableName(name) && name !== "LEGACY_STORAGE_KEYS" && hasI18nLiteral(node.initializer)) report(node, `var ${name}`);
|
||||
}
|
||||
if (ts.isCallExpression(node) && isStructuralCall(node) && hasI18nLiteral(node.arguments[0])) {
|
||||
report(node.arguments[0], `call ${node.expression.getText(source)}`);
|
||||
}
|
||||
if (ts.isCallExpression(node)) {
|
||||
node.arguments.forEach((argument, index) => {
|
||||
if (isStructuralCallArgument(node, index) && hasI18nLiteral(argument)) {
|
||||
report(argument, `call ${node.expression.getText(source)} argument ${index + 1}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (ts.isNewExpression(node) && node.expression.getText(source) === "CustomEvent" && hasI18nLiteral(node.arguments?.[0])) {
|
||||
report(node.arguments[0], "new CustomEvent");
|
||||
}
|
||||
if (ts.isArrayLiteralExpression(node) && hasI18nLiteral(node) && isLookupArray(node)) {
|
||||
report(node, "lookup array");
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(source);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function scanGeneratedCatalogs(files) {
|
||||
const findings = [];
|
||||
for (const file of files) {
|
||||
const translations = loadGeneratedTranslations(file);
|
||||
for (const [language, dictionary] of Object.entries(translations)) {
|
||||
for (const [key, value] of Object.entries(dictionary)) {
|
||||
if (looksStructuralCatalogValue(String(value), key)) {
|
||||
findings.push(`${file} ${language} ${key} has structural value ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function rgFiles(root) {
|
||||
try {
|
||||
const output = execFileSync("rg", ["--files", root], { encoding: "utf8" }).trim();
|
||||
return output ? output.split("\n") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function nodeName(name) {
|
||||
if (!name) return "";
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
|
||||
return name.getText();
|
||||
}
|
||||
|
||||
function isStructuralName(name) {
|
||||
if (!name) return false;
|
||||
if (name === "aria-label" || name === "alt" || name === "placeholder" || name === "title") return false;
|
||||
if (name.startsWith("data-")) return true;
|
||||
return structuralNames.has(name) ||
|
||||
name.endsWith("ClassName") ||
|
||||
/(?:^|_)(?:id|ids|key|keys|slug|code|path|url|endpoint|route|scope|permission|capability|module|event|storage)(?:_|$)/i.test(name) ||
|
||||
/(?:Id|IDs|Key|Keys|Slug|Code|Path|Url|URL|Endpoint|Route|Scope|Permission|Capability|Module|Event|Storage)$/.test(name);
|
||||
}
|
||||
|
||||
function hasI18nLiteral(node) {
|
||||
let found = false;
|
||||
function visit(child) {
|
||||
if (
|
||||
(ts.isStringLiteral(child) || ts.isNoSubstitutionTemplateLiteral(child) || ts.isTemplateHead(child) || ts.isTemplateMiddle(child) || ts.isTemplateTail(child)) &&
|
||||
child.text.includes("i18n:")
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
if (!found) ts.forEachChild(child, visit);
|
||||
}
|
||||
if (node) visit(node);
|
||||
return found;
|
||||
}
|
||||
|
||||
function isStringLiteralLike(node) {
|
||||
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
|
||||
}
|
||||
|
||||
function isStructuralStringPosition(node, file) {
|
||||
const parent = node.parent;
|
||||
if (!parent) return false;
|
||||
if (ts.isPropertyAssignment(parent) && parent.name === node) return true;
|
||||
if (ts.isElementAccessExpression(parent) && parent.argumentExpression === node) return true;
|
||||
if (ts.isLiteralTypeNode(parent)) return true;
|
||||
if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent)) return true;
|
||||
if (ts.isImportTypeNode(parent)) return true;
|
||||
if (ts.isCallExpression(parent) && isStructuralCall(parent) && parent.arguments[0] === node) return true;
|
||||
if (ts.isNewExpression(parent) && parent.expression.getText() === "CustomEvent" && parent.arguments?.[0] === node) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllowedStructuralStringPosition(node, file) {
|
||||
if (file.endsWith("/utils/fieldHelp.ts") && ts.isPropertyAssignment(node.parent) && node.parent.name === node) return true;
|
||||
return hasAncestorVariable(node, "LEGACY_STORAGE_KEYS");
|
||||
}
|
||||
|
||||
function isStructuralVariableName(name) {
|
||||
return /(?:^|_)(?:CLASS|CLASSNAME|STYLE|ID|KEY|SLUG|CODE|PATH|URL|ENDPOINT|ROUTE|SCOPE|PERMISSION|CAPABILITY|EVENT|STORAGE|ALGORITHM)(?:_|$)/i.test(name) ||
|
||||
/(?:ClassName|Style|Id|ID|Key|Slug|Code|Path|Url|URL|Endpoint|Route|Scope|Permission|Capability|Event|Storage|Algorithm)$/.test(name);
|
||||
}
|
||||
|
||||
function isStructuralCall(node) {
|
||||
const expression = node.expression.getText();
|
||||
return /(?:^|\.)(?:getItem|setItem|removeItem|addEventListener|removeEventListener|dispatchEvent)$/.test(expression);
|
||||
}
|
||||
|
||||
function isStructuralCallArgument(node, index) {
|
||||
const expressionName = callExpressionName(node);
|
||||
if (isStructuralCall(node) && index === 0) return true;
|
||||
if ((expressionName === "metadataText" || expressionName === "metadataFlag" || expressionName === "getText" || expressionName === "getBool") && index === 1) return true;
|
||||
if (expressionName === "createAttachmentBasePath" && index === 0) return true;
|
||||
if (expressionName === "updateNested" && index === 1) return true;
|
||||
if ((expressionName === "hasScope" || expressionName === "hasAnyScope") && index === 1) return true;
|
||||
if ((expressionName === "usePlatformModuleInstalled" || expressionName === "usePlatformUiCapability") && index === 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function callExpressionName(node) {
|
||||
const expression = node.expression;
|
||||
if (ts.isIdentifier(expression)) return expression.text;
|
||||
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
|
||||
return expression.getText();
|
||||
}
|
||||
|
||||
function isLookupArray(node) {
|
||||
let parent = node.parent;
|
||||
while (parent && (ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent))) {
|
||||
parent = parent.parent;
|
||||
}
|
||||
return Boolean(
|
||||
parent &&
|
||||
ts.isPropertyAccessExpression(parent) &&
|
||||
parent.expression === node &&
|
||||
["includes", "indexOf"].includes(parent.name.text) &&
|
||||
ts.isCallExpression(parent.parent)
|
||||
);
|
||||
}
|
||||
|
||||
function isAlgorithmNameProperty(node) {
|
||||
if (nodeName(node.name) !== "name") return false;
|
||||
const objectLiteral = node.parent;
|
||||
if (!ts.isObjectLiteralExpression(objectLiteral)) return false;
|
||||
if (objectLiteral.properties.some((property) => ts.isPropertyAssignment(property) && ["hash", "namedCurve"].includes(nodeName(property.name)))) return true;
|
||||
let parent = objectLiteral.parent;
|
||||
while (parent && (ts.isConditionalExpression(parent) || ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent))) {
|
||||
parent = parent.parent;
|
||||
}
|
||||
if (ts.isVariableDeclaration(parent)) return /Algorithm$/i.test(nodeName(parent.name));
|
||||
if (ts.isPropertyAssignment(parent)) return /Algorithm$/i.test(nodeName(parent.name));
|
||||
return false;
|
||||
}
|
||||
|
||||
function isListOptionValueProperty(node) {
|
||||
if (nodeName(node.name) !== "value") return false;
|
||||
const objectLiteral = node.parent;
|
||||
if (!ts.isObjectLiteralExpression(objectLiteral)) return false;
|
||||
return objectLiteral.properties.some((property) => ts.isPropertyAssignment(property) && nodeName(property.name) === "label");
|
||||
}
|
||||
|
||||
function isStructuralJsxValue(node) {
|
||||
if (nodeName(node.name) !== "value") return false;
|
||||
const parent = node.parent;
|
||||
if (!ts.isJsxOpeningElement(parent) && !ts.isJsxSelfClosingElement(parent)) return false;
|
||||
return parent.tagName.getText() === "option";
|
||||
}
|
||||
|
||||
function hasAncestorVariable(node, variableName) {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (ts.isVariableDeclaration(current) && nodeName(current.name) === variableName) return true;
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const structuralNames = new Set([
|
||||
"action",
|
||||
"actionId",
|
||||
"algorithm",
|
||||
"allOf",
|
||||
"anyOf",
|
||||
"apiKey",
|
||||
"authMethod",
|
||||
"capability",
|
||||
"capabilityId",
|
||||
"class",
|
||||
"className",
|
||||
"code",
|
||||
"columnType",
|
||||
"data-testid",
|
||||
"endpoint",
|
||||
"event",
|
||||
"eventName",
|
||||
"eventType",
|
||||
"field",
|
||||
"for",
|
||||
"form",
|
||||
"hash",
|
||||
"href",
|
||||
"htmlFor",
|
||||
"iconName",
|
||||
"id",
|
||||
"kind",
|
||||
"key",
|
||||
"method",
|
||||
"moduleId",
|
||||
"nameAttribute",
|
||||
"namedCurve",
|
||||
"ownerModule",
|
||||
"owner_module",
|
||||
"path",
|
||||
"permission",
|
||||
"permissions",
|
||||
"rel",
|
||||
"requiredScopes",
|
||||
"role",
|
||||
"route",
|
||||
"scope",
|
||||
"scopeId",
|
||||
"scopeType",
|
||||
"slug",
|
||||
"status",
|
||||
"storageKey",
|
||||
"style",
|
||||
"target",
|
||||
"to",
|
||||
"tone",
|
||||
"type",
|
||||
"url",
|
||||
"variant"
|
||||
]);
|
||||
|
||||
function loadGeneratedTranslations(file) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const output = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
|
||||
}).outputText;
|
||||
const sandbox = { exports: {}, module: { exports: {} }, require: () => ({}) };
|
||||
vm.runInNewContext(output, sandbox, { filename: file });
|
||||
return sandbox.exports.generatedTranslations ?? sandbox.module.exports.generatedTranslations ?? {};
|
||||
}
|
||||
|
||||
const cssUnits = /^(?:-?\d+(?:\.\d+)?(?:px|r?em|vh|vw|vmin|vmax|%|s|ms)?|0)(?:\s+(?:-?\d+(?:\.\d+)?(?:px|r?em|vh|vw|vmin|vmax|%|s|ms)?|0)){0,3}$/i;
|
||||
const cssFunction = /^(?:minmax|repeat|calc|var|clamp|rgba?|hsla?)\(/i;
|
||||
const color = /^(?:#[0-9a-f]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\))$/i;
|
||||
const cssIdentifier = /^-?[_a-zA-Z]+[_a-zA-Z0-9-]*$/;
|
||||
const emailAddress = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
const emailAddressList = /^[^@\s;,]+@[^@\s;,]+\.[^@\s;,]+(?:\s*[;,]\s*[^@\s;,]+@[^@\s;,]+\.[^@\s;,]+)*$/;
|
||||
const urlLike = /^(?:https?|webcal):\/\//i;
|
||||
const packageSpecifier = /^@[a-z0-9-]+\/[a-z0-9-]+$/i;
|
||||
const shellCommand = /^(?:systemctl|pg_dump|pg_restore|npm|pnpm|uv|pip|python)\b/;
|
||||
const wildcardDomain = /^\*\.[a-z0-9.-]+$/i;
|
||||
const technicalDottedIdentifier = /^[a-z][a-z0-9]*(?:[._:-][a-z0-9][a-z0-9-]*)+$/;
|
||||
const environmentReference = /^(?:env|secret):[A-Z0-9_]+$/;
|
||||
const codeEllipsis = /^[a-z][a-z0-9_-]*-\.\.\.$/;
|
||||
const globOrPathChoice = /^[\w./*-]+(?:\s+or\s+[\w./*-]+)+$/i;
|
||||
const multiLineStructuralList = /^(?:[A-Za-z0-9_.:/-]+[*?]?(?:\n|$))+$/;
|
||||
const cryptographicIdentifier = /^(?:ECDSA|Ed25519|RSASSA-PKCS1-v1_5|RSA-PSS|AES-GCM|SHA-\d+)$/;
|
||||
const classStateTokens = new Set(["active", "subtle", "success", "neutral", "danger", "disabled", "compact", "hidden", "block", "inline"]);
|
||||
const allowedStructuralLookingValues = new Set(["active tenant", "campaign-local settings", "govoplan-files"]);
|
||||
const allowedStructuralLookingKeys = /(?:active_tenant|python_package_placeholder|manifest_signature_key|remote_entry|remote_manifest)/;
|
||||
|
||||
function looksStructuralCatalogValue(value, key) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || allowedStructuralLookingValues.has(trimmed) || allowedStructuralLookingKeys.test(key)) return false;
|
||||
if (emailAddress.test(trimmed) || emailAddressList.test(trimmed) || cryptographicIdentifier.test(trimmed)) return true;
|
||||
if (environmentReference.test(trimmed) || codeEllipsis.test(trimmed)) return true;
|
||||
if (globOrPathChoice.test(trimmed) && /[/*.]/.test(trimmed)) return true;
|
||||
if (urlLike.test(trimmed) || packageSpecifier.test(trimmed) || shellCommand.test(trimmed) || wildcardDomain.test(trimmed)) return true;
|
||||
if (technicalDottedIdentifier.test(trimmed)) return true;
|
||||
if (trimmed.includes("\n") && multiLineStructuralList.test(trimmed)) return true;
|
||||
if (cssUnits.test(trimmed) || cssFunction.test(trimmed) || color.test(trimmed)) return true;
|
||||
if (/^[.#][A-Za-z_][\w-]*(?:\s*[.#][A-Za-z_][\w-]*)*$/.test(trimmed)) return true;
|
||||
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
if (!tokens.every((token) => cssIdentifier.test(token))) return false;
|
||||
if (!tokens.some((token) => token.includes("-") || token.startsWith("is-") || token.startsWith("has-") || classStateTokens.has(token))) return false;
|
||||
if (value !== trimmed) return true;
|
||||
if (tokens.length > 1 && tokens.every((token) => token === token.toLowerCase()) && tokens.some((token) => token.includes("-") || token.startsWith("is-") || token.startsWith("has-"))) return true;
|
||||
if (tokens.length === 1 && (/^(?:is-|has-|with-)/.test(trimmed) || (trimmed.includes("-") && trimmed === trimmed.toLowerCase()))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceFindings = scanStructuralSourcePositions(sourceRoots);
|
||||
const catalogFindings = scanGeneratedCatalogs(generatedCatalogs);
|
||||
const findings = [...sourceFindings, ...catalogFindings];
|
||||
|
||||
if (findings.length) {
|
||||
console.error(findings.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("No structural i18n markers found in source positions or generated catalogs.");
|
||||
@@ -4,8 +4,11 @@ const packageByModule = {
|
||||
access: "@govoplan/access-webui",
|
||||
admin: "@govoplan/admin-webui",
|
||||
campaigns: "@govoplan/campaign-webui",
|
||||
dashboard: "@govoplan/dashboard-webui",
|
||||
docs: "@govoplan/docs-webui",
|
||||
files: "@govoplan/files-webui",
|
||||
mail: "@govoplan/mail-webui"
|
||||
mail: "@govoplan/mail-webui",
|
||||
ops: "@govoplan/ops-webui"
|
||||
};
|
||||
|
||||
const cases = [
|
||||
@@ -13,12 +16,14 @@ const cases = [
|
||||
{ name: "access-only", modules: ["access"] },
|
||||
{ name: "admin-only", modules: ["admin"] },
|
||||
{ name: "access-with-admin", modules: ["access", "admin"] },
|
||||
{ name: "dashboard-only", modules: ["dashboard"] },
|
||||
{ name: "files-only", modules: ["files"] },
|
||||
{ name: "mail-only", modules: ["mail"] },
|
||||
{ name: "campaign-only", modules: ["campaigns"] },
|
||||
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
|
||||
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
|
||||
{ name: "full-product", modules: ["access", "admin", "campaigns", "files", "mail"] }
|
||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||
{ name: "full-product", modules: ["access", "admin", "dashboard", "campaigns", "files", "mail", "docs", "ops"] }
|
||||
];
|
||||
|
||||
const npmExec = process.env.npm_execpath;
|
||||
@@ -27,13 +32,20 @@ const baseArgs = npmExec ? [npmExec, "run", "build"] : ["run", "build"];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const packages = testCase.modules.map((moduleId) => packageByModule[moduleId]).join(",");
|
||||
const env = {
|
||||
...process.env,
|
||||
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages
|
||||
};
|
||||
delete env.npm_config_tmp;
|
||||
delete env.NPM_CONFIG_TMP;
|
||||
if (process.env.GOVOPLAN_NPM_USERCONFIG) {
|
||||
env.NPM_CONFIG_USERCONFIG = process.env.GOVOPLAN_NPM_USERCONFIG;
|
||||
delete env.npm_config_userconfig;
|
||||
}
|
||||
console.log(`\n== WebUI module permutation: ${testCase.name} ==`);
|
||||
const result = spawnSync(command, baseArgs, {
|
||||
cwd: new URL("..", import.meta.url),
|
||||
env: {
|
||||
...process.env,
|
||||
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages
|
||||
},
|
||||
env,
|
||||
stdio: "inherit"
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
|
||||
@@ -1,34 +1,46 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { fetchMe } from "./api/auth";
|
||||
import { fetchMe, updateProfile } from "./api/auth";
|
||||
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo, PlatformWebModule } from "./types";
|
||||
import type { ApiSettings, AuthInfo, AuthTenant, AuthTenantMembership, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
import LoginModal from "./features/auth/LoginModal";
|
||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||
import { firstAccessibleRoute, loadRemoteWebModules, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { firstAccessibleRoute, loadRemoteWebModules, moduleInstalled, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
||||
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
|
||||
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
||||
compact_tables: false,
|
||||
show_inline_help_hints: true,
|
||||
reduce_motion: false,
|
||||
sticky_section_sidebars: true,
|
||||
theme: "system"
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [settings, setSettings] = useState<ApiSettings>(() => loadApiSettings());
|
||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||
const [checkingSession, setCheckingSession] = useState(true);
|
||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||
const [maintenanceMode, setMaintenanceMode] = useState<{ enabled: boolean; message?: string | null }>({ enabled: false, message: null });
|
||||
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||
const [reloginMessage, setReloginMessage] = useState("");
|
||||
|
||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||
const moduleTranslations = useMemo(() => webModules.map((module) => module.translations).filter(Boolean), [webModules]);
|
||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||
|
||||
function updateSettings(next: ApiSettings) {
|
||||
setSettings(next);
|
||||
@@ -37,7 +49,7 @@ export default function App() {
|
||||
|
||||
function updateAuth(next: AuthInfo | null, accessToken?: string) {
|
||||
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
|
||||
setAuth(next);
|
||||
setAuth(next ? normalizeAuthInfo(next) : null);
|
||||
if (accessToken !== undefined) {
|
||||
setSettings(nextSettings);
|
||||
saveApiSettings(nextSettings);
|
||||
@@ -45,16 +57,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
function authFromLoginResponse(response: LoginResponse): AuthInfo {
|
||||
const active = response.active_tenant ?? response.tenant;
|
||||
return {
|
||||
user: response.user,
|
||||
tenant: active,
|
||||
active_tenant: active,
|
||||
tenants: response.tenants ?? [active],
|
||||
scopes: response.scopes,
|
||||
roles: response.roles,
|
||||
groups: response.groups
|
||||
};
|
||||
return normalizeAuthInfo(response);
|
||||
}
|
||||
|
||||
function handlePublicLogin(response: LoginResponse) {
|
||||
@@ -70,7 +73,7 @@ export default function App() {
|
||||
function handleAuthRequired(event: Event) {
|
||||
if (!auth) return;
|
||||
const detail = (event as CustomEvent<AuthRequiredEventDetail>).detail;
|
||||
setReloginMessage(detail?.message || "Your session has expired. Sign in again to continue.");
|
||||
setReloginMessage(detail?.message || "i18n:govoplan-core.your_session_has_expired_sign_in_again_to_contin.4cc6a1b3");
|
||||
}
|
||||
|
||||
window.addEventListener(AUTH_REQUIRED_EVENT, handleAuthRequired);
|
||||
@@ -79,35 +82,57 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchPlatformStatus(settings)
|
||||
.then((response) => {
|
||||
if (!cancelled) setMaintenanceMode(response.maintenance_mode);
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
async function refreshPlatformStatus() {
|
||||
try {
|
||||
const response = await fetchPlatformStatus(settings);
|
||||
if (cancelled) return;
|
||||
setMaintenanceMode(response.maintenance_mode);
|
||||
if (response.i18n) {
|
||||
setSystemLanguages({
|
||||
available: response.i18n.available_languages.map((item) => ({
|
||||
code: item.code,
|
||||
label: item.label,
|
||||
nativeLabel: item.native_label ?? undefined
|
||||
})),
|
||||
enabled: response.i18n.enabled_languages,
|
||||
defaultLanguage: response.i18n.default_language
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setMaintenanceMode({ enabled: false, message: null });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}
|
||||
}
|
||||
|
||||
void refreshPlatformStatus();
|
||||
const interval = window.setInterval(() => {void refreshPlatformStatus();}, 30_000);
|
||||
window.addEventListener("focus", refreshPlatformStatus);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", refreshPlatformStatus);
|
||||
};
|
||||
}, [settings.apiBaseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCheckingSession(true);
|
||||
fetchMe(settings)
|
||||
.then((me) => { if (!cancelled) setAuth(me); })
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
const cleared = { ...settings, accessToken: "" };
|
||||
setSettings(cleared);
|
||||
saveApiSettings(cleared);
|
||||
setAuth(null);
|
||||
setPlatformModules(null);
|
||||
setRemoteWebModules([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setCheckingSession(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
fetchMe(settings).
|
||||
then((me) => {if (!cancelled) setAuth(normalizeAuthInfo(me));}).
|
||||
catch(() => {
|
||||
if (!cancelled) {
|
||||
const cleared = { ...settings, accessToken: "" };
|
||||
setSettings(cleared);
|
||||
saveApiSettings(cleared);
|
||||
setAuth(null);
|
||||
setPlatformModules(null);
|
||||
setRemoteWebModules([]);
|
||||
}
|
||||
}).
|
||||
finally(() => {
|
||||
if (!cancelled) setCheckingSession(false);
|
||||
});
|
||||
return () => {cancelled = true;};
|
||||
}, [settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,10 +144,10 @@ export default function App() {
|
||||
function loadModules() {
|
||||
inFlight = true;
|
||||
lastRefreshAt = Date.now();
|
||||
return fetchPlatformModules(settings)
|
||||
.then((response) => { if (!cancelled) setPlatformModules(response.modules); })
|
||||
.catch(() => { if (!cancelled) setPlatformModules(null); })
|
||||
.finally(() => { inFlight = false; });
|
||||
return fetchPlatformModules(settings).
|
||||
then((response) => {if (!cancelled) setPlatformModules(response.modules);}).
|
||||
catch(() => {if (!cancelled) setPlatformModules(null);}).
|
||||
finally(() => {inFlight = false;});
|
||||
}
|
||||
|
||||
void loadModules();
|
||||
@@ -146,19 +171,39 @@ export default function App() {
|
||||
window.removeEventListener("focus", refreshVisibleModules);
|
||||
document.removeEventListener("visibilitychange", refreshVisibleModules);
|
||||
};
|
||||
}, [auth?.user.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const preferences = auth?.user.ui_preferences ?? DEFAULT_UI_PREFERENCES;
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("ui-compact-tables", preferences.compact_tables);
|
||||
root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints);
|
||||
root.classList.toggle("ui-reduce-motion", preferences.reduce_motion);
|
||||
root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars);
|
||||
if (preferences.theme === "system") {
|
||||
delete root.dataset.theme;
|
||||
} else {
|
||||
root.dataset.theme = preferences.theme;
|
||||
}
|
||||
}, [
|
||||
auth?.user.ui_preferences?.compact_tables,
|
||||
auth?.user.ui_preferences?.show_inline_help_hints,
|
||||
auth?.user.ui_preferences?.reduce_motion,
|
||||
auth?.user.ui_preferences?.sticky_section_sidebars,
|
||||
auth?.user.ui_preferences?.theme
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!auth || !platformModules?.length) {
|
||||
setRemoteWebModules([]);
|
||||
return () => { cancelled = true; };
|
||||
return () => {cancelled = true;};
|
||||
}
|
||||
loadRemoteWebModules(platformModules, localWebModules)
|
||||
.then((modules) => { if (!cancelled) setRemoteWebModules(modules); })
|
||||
.catch(() => { if (!cancelled) setRemoteWebModules([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [auth?.user.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
||||
loadRemoteWebModules(platformModules, localWebModules).
|
||||
then((modules) => {if (!cancelled) setRemoteWebModules(modules);}).
|
||||
catch(() => {if (!cancelled) setRemoteWebModules([]);});
|
||||
return () => {cancelled = true;};
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth) return;
|
||||
@@ -174,12 +219,12 @@ export default function App() {
|
||||
inFlight = true;
|
||||
lastRefreshAt = now;
|
||||
try {
|
||||
setAuth(await fetchMe(settings));
|
||||
setAuth(normalizeAuthInfo(await fetchMe(settings)));
|
||||
} catch {
|
||||
|
||||
|
||||
// A background refresh must not log the user out on a transient network error.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
} finally {inFlight = false;}
|
||||
}
|
||||
|
||||
window.addEventListener("focus", refreshVisibleSession);
|
||||
@@ -188,87 +233,189 @@ export default function App() {
|
||||
window.removeEventListener("focus", refreshVisibleSession);
|
||||
document.removeEventListener("visibilitychange", refreshVisibleSession);
|
||||
};
|
||||
}, [auth?.user.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
if (checkingSession) {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}>
|
||||
<div className="public-landing">
|
||||
<section className="public-card">
|
||||
<div className="public-kicker">GovOPlaN</div>
|
||||
<h1>Checking session...</h1>
|
||||
<p>Please wait while the local session is verified.</p>
|
||||
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
|
||||
<h1>i18n:govoplan-core.checking_session.e7d81968</h1>
|
||||
<p>i18n:govoplan-core.please_wait_while_the_local_session_is_verified.1dbd8fd3</p>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
);
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}>
|
||||
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
);
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
}
|
||||
|
||||
const defaultRoute = firstAccessibleRoute(auth, webModules);
|
||||
const authAvailableLanguages = auth.available_languages?.map((item) => ({
|
||||
code: item.code,
|
||||
label: item.label,
|
||||
nativeLabel: item.native_label ?? undefined
|
||||
}));
|
||||
|
||||
function persistLanguagePreference(code: string) {
|
||||
void updateProfile(settings, { preferred_language: code }).then((next) => updateAuth(next)).catch(() => undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformLanguageProvider
|
||||
systemAvailableLanguages={authAvailableLanguages ?? systemLanguages?.available}
|
||||
systemEnabledLanguageCodes={auth.enabled_language_codes ?? systemLanguages?.enabled}
|
||||
userEnabledLanguageCodes={auth.user?.enabled_language_codes}
|
||||
defaultLanguage={auth.default_language ?? systemLanguages?.defaultLanguage ?? (auth.active_tenant ?? auth.tenant).default_locale}
|
||||
preferredLanguageCode={auth.user?.preferred_language ?? auth.default_language}
|
||||
onLanguageChange={persistLanguagePreference}
|
||||
moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems}>
|
||||
<Suspense fallback={<div className="content-pad"><p className="muted">Loading module...</p></div>}>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode}>
|
||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage settings={settings} />} />
|
||||
{moduleRoutes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={
|
||||
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
|
||||
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
||||
{moduleRoutes.map((route) =>
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={
|
||||
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
|
||||
{route.render({ settings, auth, onAuthChange: updateAuth })}
|
||||
</PermissionBoundary>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
} />
|
||||
|
||||
)}
|
||||
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
||||
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
{reloginMessage && (
|
||||
<LoginModal
|
||||
settings={settings}
|
||||
title="Session expired"
|
||||
message={reloginMessage}
|
||||
onClose={() => setReloginMessage("")}
|
||||
onLogin={handleRelogin}
|
||||
/>
|
||||
)}
|
||||
{reloginMessage &&
|
||||
<LoginModal
|
||||
settings={settings}
|
||||
title="i18n:govoplan-core.session_expired.b828190e"
|
||||
message={reloginMessage}
|
||||
onClose={() => setReloginMessage("")}
|
||||
onLogin={handleRelogin} />
|
||||
|
||||
}
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
);
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
}
|
||||
|
||||
type AuthPayload = Partial<AuthInfo> & {
|
||||
principal?: AuthInfo["principal"];
|
||||
user?: Partial<AuthUser> | null;
|
||||
tenant?: AuthTenant | null;
|
||||
active_tenant?: AuthTenant | null;
|
||||
tenants?: AuthTenantMembership[] | null;
|
||||
};
|
||||
|
||||
function normalizeAuthInfo(response: AuthPayload): AuthInfo {
|
||||
const principal = response.principal ?? null;
|
||||
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
|
||||
const user = normalizeAuthUser(response.user, principal);
|
||||
|
||||
if (!activeTenant) {
|
||||
throw new Error("Authentication response did not include an active tenant.");
|
||||
}
|
||||
if (!user) {
|
||||
throw new Error("Authentication response did not include a user.");
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
tenant: activeTenant,
|
||||
active_tenant: activeTenant,
|
||||
tenants: response.tenants ?? [activeTenant],
|
||||
scopes: response.scopes ?? principal?.scopes ?? [],
|
||||
roles: response.roles ?? [],
|
||||
groups: response.groups ?? [],
|
||||
principal,
|
||||
available_languages: response.available_languages ?? [],
|
||||
enabled_language_codes: response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [],
|
||||
default_language: response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en"
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal: AuthInfo["principal"]): AuthUser | null {
|
||||
if (user?.id && user.account_id) {
|
||||
return {
|
||||
id: user.id,
|
||||
account_id: user.account_id,
|
||||
email: user.email ?? principal?.email ?? "",
|
||||
display_name: user.display_name ?? principal?.display_name ?? principal?.email ?? null,
|
||||
tenant_display_name: user.tenant_display_name ?? null,
|
||||
is_tenant_admin: user.is_tenant_admin ?? false,
|
||||
password_reset_required: user.password_reset_required ?? false,
|
||||
preferred_language: user.preferred_language ?? null,
|
||||
enabled_language_codes: user.enabled_language_codes ?? [],
|
||||
ui_preferences: normalizeUiPreferences(user.ui_preferences)
|
||||
};
|
||||
}
|
||||
|
||||
if (!principal?.membership_id || !principal.account_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: principal.membership_id,
|
||||
account_id: principal.account_id,
|
||||
email: principal.email ?? "",
|
||||
display_name: principal.display_name ?? principal.email ?? null,
|
||||
tenant_display_name: principal.display_name ?? null,
|
||||
is_tenant_admin: false,
|
||||
password_reset_required: false,
|
||||
preferred_language: null,
|
||||
enabled_language_codes: [],
|
||||
ui_preferences: DEFAULT_UI_PREFERENCES
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
|
||||
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
|
||||
return {
|
||||
compact_tables: Boolean(value?.compact_tables ?? DEFAULT_UI_PREFERENCES.compact_tables),
|
||||
show_inline_help_hints: Boolean(value?.show_inline_help_hints ?? DEFAULT_UI_PREFERENCES.show_inline_help_hints),
|
||||
reduce_motion: Boolean(value?.reduce_motion ?? DEFAULT_UI_PREFERENCES.reduce_motion),
|
||||
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
|
||||
theme
|
||||
};
|
||||
}
|
||||
|
||||
function mergeWebModules(localModules: PlatformWebModule[], remoteModules: PlatformWebModule[]): PlatformWebModule[] {
|
||||
if (remoteModules.length === 0) return localModules;
|
||||
const seen = new Set(localModules.map((module) => module.id));
|
||||
return [
|
||||
...localModules,
|
||||
...remoteModules.filter((module) => {
|
||||
if (seen.has(module.id)) return false;
|
||||
seen.add(module.id);
|
||||
return true;
|
||||
})
|
||||
];
|
||||
...localModules,
|
||||
...remoteModules.filter((module) => {
|
||||
if (seen.has(module.id)) return false;
|
||||
seen.add(module.id);
|
||||
return true;
|
||||
})];
|
||||
|
||||
}
|
||||
|
||||
@@ -150,11 +150,20 @@ export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group"
|
||||
export type PolicySourceStep = {
|
||||
scope_type: string;
|
||||
scope_id?: string | null;
|
||||
path: string;
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
|
||||
};
|
||||
|
||||
export type PolicyDecision = {
|
||||
allowed: boolean;
|
||||
reason?: string | null;
|
||||
source_path: PolicySourceStep[];
|
||||
requirements: string[];
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyScopeResponse = {
|
||||
scope_type: PrivacyRetentionPolicyScope;
|
||||
scope_id?: string | null;
|
||||
@@ -165,20 +174,43 @@ export type PrivacyRetentionPolicyScopeResponse = {
|
||||
parent_policy_sources?: PolicySourceStep[];
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyExplainResponse = {
|
||||
scope_type: PrivacyRetentionPolicyScope;
|
||||
scope_id?: string | null;
|
||||
decision: PolicyDecision;
|
||||
effective_policy: PrivacyRetentionPolicy;
|
||||
parent_policy?: PrivacyRetentionPolicy | null;
|
||||
effective_policy_sources?: PolicySourceStep[];
|
||||
parent_policy_sources?: PolicySourceStep[];
|
||||
blocked_fields: PrivacyRetentionPolicyFieldKey[];
|
||||
};
|
||||
|
||||
export type SystemSettingsItem = {
|
||||
default_locale: string;
|
||||
allow_tenant_custom_groups: boolean;
|
||||
allow_tenant_custom_roles: boolean;
|
||||
allow_tenant_api_keys: boolean;
|
||||
privacy_retention_policy: PrivacyRetentionPolicy;
|
||||
maintenance_mode?: { enabled: boolean; message?: string | null };
|
||||
available_languages?: LanguagePackage[];
|
||||
enabled_language_codes?: string[];
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type LanguagePackage = {
|
||||
code: string;
|
||||
label: string;
|
||||
native_label?: string | null;
|
||||
};
|
||||
|
||||
export type TenantSettingsItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
default_locale: string;
|
||||
available_languages: LanguagePackage[];
|
||||
system_enabled_language_codes: string[];
|
||||
enabled_language_codes: string[];
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -192,6 +224,116 @@ export type RetentionRunResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ConfigurationSafetyField = {
|
||||
key: string;
|
||||
label: string;
|
||||
owner_module: string;
|
||||
scope: "system" | "tenant" | "user" | "group" | "campaign";
|
||||
storage: string;
|
||||
ui_managed: boolean;
|
||||
risk: "low" | "medium" | "high" | "destructive";
|
||||
secret_handling: "none" | "reference_only" | "env_only";
|
||||
required_scopes: string[];
|
||||
dry_run_required: boolean;
|
||||
validation_required: boolean;
|
||||
policy_explanation_required: boolean;
|
||||
audit_event?: string | null;
|
||||
maintenance_required: boolean;
|
||||
two_person_approval_required: boolean;
|
||||
rollback_history_required: boolean;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type ConfigurationChangeSafetyPlan = {
|
||||
key: string;
|
||||
allowed: boolean;
|
||||
field?: ConfigurationSafetyField | null;
|
||||
risk?: ConfigurationSafetyField["risk"] | null;
|
||||
missing_scopes: string[];
|
||||
dry_run_required: boolean;
|
||||
dry_run_satisfied: boolean;
|
||||
approval_required: boolean;
|
||||
approval_satisfied: boolean;
|
||||
maintenance_required: boolean;
|
||||
maintenance_satisfied: boolean;
|
||||
rollback_history_required: boolean;
|
||||
secret_handling: ConfigurationSafetyField["secret_handling"];
|
||||
audit_event?: string | null;
|
||||
policy_explanation?: string | null;
|
||||
blockers: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type ConfigurationChangeRequest = {
|
||||
id: string;
|
||||
key: string;
|
||||
label?: string;
|
||||
target?: Record<string, unknown>;
|
||||
dry_run: boolean;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
updated_at: string;
|
||||
status: "pending_approval" | "approved" | "applied" | "rejected" | string;
|
||||
approvals: Array<Record<string, unknown>>;
|
||||
plan: ConfigurationChangeSafetyPlan;
|
||||
value_preview?: unknown;
|
||||
};
|
||||
|
||||
export type ConfigurationChangeRecord = {
|
||||
id: string;
|
||||
version: number;
|
||||
key: string;
|
||||
target?: Record<string, unknown>;
|
||||
actor_user_id: string;
|
||||
approval_request_id?: string | null;
|
||||
approval_user_ids: string[];
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
rollback_value?: unknown;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ConfigurationPackageDiagnostic = {
|
||||
severity: "blocker" | "warning" | "info";
|
||||
code: string;
|
||||
message: string;
|
||||
module_id?: string | null;
|
||||
object_ref?: string | null;
|
||||
resolution?: string | null;
|
||||
};
|
||||
|
||||
export type ConfigurationPackageRequiredData = {
|
||||
key: string;
|
||||
label: string;
|
||||
data_type: string;
|
||||
required: boolean;
|
||||
secret: boolean;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type ConfigurationPackagePlanItem = {
|
||||
action: "create" | "update" | "bind" | "skip" | "blocked" | "noop";
|
||||
module_id: string;
|
||||
fragment_type: string;
|
||||
fragment_id?: string | null;
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
export type ConfigurationPackageFragment = {
|
||||
module_id: string;
|
||||
fragment_type: string;
|
||||
fragment_id?: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ConfigurationPackageRunPayload = {
|
||||
package: Record<string, unknown>;
|
||||
tenant_id?: string | null;
|
||||
supplied_data?: Record<string, unknown>;
|
||||
change_request_id?: string | null;
|
||||
};
|
||||
|
||||
export type GovernanceAssignment = {
|
||||
tenant_id: string;
|
||||
mode: "available" | "required";
|
||||
@@ -289,7 +431,7 @@ export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettin
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/settings");
|
||||
}
|
||||
|
||||
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string }): Promise<TenantSettingsItem> {
|
||||
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string; enabled_language_codes?: string[] | null }): Promise<TenantSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
@@ -502,6 +644,10 @@ export type SystemSettingsUpdatePayload = {
|
||||
allow_tenant_custom_roles: boolean;
|
||||
allow_tenant_api_keys: boolean;
|
||||
privacy_retention_policy?: PrivacyRetentionPolicy | null;
|
||||
maintenance_mode?: { enabled: boolean; message?: string | null } | null;
|
||||
available_languages?: LanguagePackage[] | null;
|
||||
enabled_language_codes?: string[] | null;
|
||||
change_request_id?: string | null;
|
||||
};
|
||||
|
||||
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
|
||||
@@ -515,31 +661,127 @@ export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyR
|
||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
|
||||
}
|
||||
|
||||
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
|
||||
export function explainPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyExplainResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) });
|
||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain${suffix}`);
|
||||
}
|
||||
|
||||
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null, changeRequestId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) });
|
||||
}
|
||||
|
||||
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
|
||||
}
|
||||
|
||||
export async function fetchConfigurationSafetyCatalog(settings: ApiSettings, includeEnvOnly = false): Promise<ConfigurationSafetyField[]> {
|
||||
const suffix = includeEnvOnly ? "?include_env_only=true" : "";
|
||||
const response = await apiFetch<{ fields: ConfigurationSafetyField[] }>(settings, `/api/v1/admin/configuration-safety${suffix}`);
|
||||
return response.fields;
|
||||
}
|
||||
|
||||
export async function planConfigurationChange(settings: ApiSettings, payload: {
|
||||
key: string;
|
||||
value?: unknown;
|
||||
dry_run?: boolean;
|
||||
maintenance_mode?: boolean;
|
||||
approval_count?: number;
|
||||
}): Promise<ConfigurationChangeSafetyPlan> {
|
||||
const response = await apiFetch<{ plan: ConfigurationChangeSafetyPlan }>(settings, "/api/v1/admin/configuration-safety/plan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return response.plan;
|
||||
}
|
||||
|
||||
export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> {
|
||||
return apiFetch(settings, "/api/v1/admin/configuration-changes");
|
||||
}
|
||||
|
||||
export async function createConfigurationChangeRequest(settings: ApiSettings, payload: {
|
||||
key: string;
|
||||
value?: unknown;
|
||||
dry_run?: boolean;
|
||||
target?: Record<string, unknown>;
|
||||
reason?: string | null;
|
||||
}): Promise<ConfigurationChangeRequest> {
|
||||
const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, "/api/v1/admin/configuration-change-requests", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return response.request;
|
||||
}
|
||||
|
||||
export async function approveConfigurationChangeRequest(settings: ApiSettings, requestId: string, reason?: string | null): Promise<ConfigurationChangeRequest> {
|
||||
const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, `/api/v1/admin/configuration-change-requests/${encodeURIComponent(requestId)}/approve`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: reason ?? null })
|
||||
});
|
||||
return response.request;
|
||||
}
|
||||
|
||||
export function fetchConfigurationPackageCatalogValidation(settings: ApiSettings): Promise<{ validation: Record<string, unknown> }> {
|
||||
return apiFetch(settings, "/api/v1/admin/configuration-packages/catalog");
|
||||
}
|
||||
|
||||
export function dryRunConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{
|
||||
diagnostics: ConfigurationPackageDiagnostic[];
|
||||
required_data: ConfigurationPackageRequiredData[];
|
||||
plan: ConfigurationPackagePlanItem[];
|
||||
}> {
|
||||
return apiFetch(settings, "/api/v1/admin/configuration-packages/dry-run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function applyConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{
|
||||
diagnostics: ConfigurationPackageDiagnostic[];
|
||||
created_refs: Record<string, string>;
|
||||
updated_refs: Record<string, string>;
|
||||
}> {
|
||||
return apiFetch(settings, "/api/v1/admin/configuration-packages/apply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function exportConfigurationPackage(settings: ApiSettings, payload: {
|
||||
tenant_id?: string | null;
|
||||
scopes?: string[];
|
||||
module_ids?: string[];
|
||||
object_refs?: string[];
|
||||
}): Promise<{
|
||||
fragments: ConfigurationPackageFragment[];
|
||||
data_requirements: ConfigurationPackageRequiredData[];
|
||||
diagnostics: ConfigurationPackageDiagnostic[];
|
||||
}> {
|
||||
return apiFetch(settings, "/api/v1/admin/configuration-packages/export", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
||||
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
||||
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
|
||||
return response.templates;
|
||||
}
|
||||
|
||||
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
||||
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
||||
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
|
||||
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string, changeRequestId?: string | null): Promise<void> {
|
||||
const suffix = changeRequestId ? `?change_request_id=${encodeURIComponent(changeRequestId)}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}${suffix}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ApiSettings, AuthInfo, LoginResponse } from "../types";
|
||||
import type { ApiSettings, AuthInfo, LoginResponse, UserUiPreferences } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export async function login(
|
||||
@@ -28,7 +28,13 @@ export async function logout(settings: ApiSettings): Promise<void> {
|
||||
|
||||
export async function updateProfile(
|
||||
settings: ApiSettings,
|
||||
payload: { display_name?: string | null; tenant_display_name?: string | null }
|
||||
payload: {
|
||||
display_name?: string | null;
|
||||
tenant_display_name?: string | null;
|
||||
preferred_language?: string | null;
|
||||
enabled_language_codes?: string[] | null;
|
||||
ui_preferences?: Partial<UserUiPreferences> | null;
|
||||
}
|
||||
): Promise<AuthInfo> {
|
||||
return apiFetch<AuthInfo>(settings, "/api/v1/auth/profile", {
|
||||
method: "PATCH",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
|
||||
const STORAGE_KEY = "multimailer.apiSettings";
|
||||
const LEGACY_STORAGE_KEYS = ["i18n:govoplan-core.multimailer_apisettings.1d1601d4"];
|
||||
const SESSION_STORAGE_KEY = "multimailer.session";
|
||||
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
|
||||
const RECENT_SAFE_REQUEST_TTL_MS = 750;
|
||||
const MAX_RECENT_SAFE_REQUESTS = 100;
|
||||
const MAX_CONDITIONAL_SAFE_REQUESTS = 200;
|
||||
|
||||
export const AUTH_REQUIRED_EVENT = "govoplan:auth-required";
|
||||
|
||||
@@ -19,8 +21,14 @@ type RecentSafeRequest = {
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
type ConditionalSafeRequest = {
|
||||
value: unknown;
|
||||
etag: string;
|
||||
};
|
||||
|
||||
const inFlightSafeRequests = new Map<string, Promise<unknown>>();
|
||||
const recentSafeRequests = new Map<string, RecentSafeRequest>();
|
||||
const conditionalSafeRequests = new Map<string, ConditionalSafeRequest>();
|
||||
let safeRequestGeneration = 0;
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -47,8 +55,20 @@ export function isApiError(error: unknown, ...statuses: number[]): error is ApiE
|
||||
* API prefix so old localStorage settings cannot produce /api/v1/api/v1 URLs.
|
||||
*/
|
||||
export function normalizeApiBaseUrl(value: string): string {
|
||||
const withoutTrailingSlash = value.trim().replace(/\/+$/, "");
|
||||
return withoutTrailingSlash.replace(/\/api(?:\/v1)?$/i, "");
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === "." || trimmed === "./") return "";
|
||||
|
||||
const withoutTrailingSlash = trimmed.replace(/\/+$/, "");
|
||||
const withoutApiPrefix = withoutTrailingSlash.replace(/\/api(?:\/v1)?$/i, "");
|
||||
|
||||
// API base is an origin, not a frontend route prefix. Path-only values such
|
||||
// as /a or . are stale/invalid local settings and would bypass Vite's /api
|
||||
// proxy by producing /a/api/v1/... requests.
|
||||
if (withoutApiPrefix.startsWith("/") && !withoutApiPrefix.startsWith("//")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return withoutApiPrefix;
|
||||
}
|
||||
|
||||
export function apiUrl(settings: ApiSettings, path: string): string {
|
||||
@@ -58,7 +78,8 @@ export function apiUrl(settings: ApiSettings, path: string): string {
|
||||
}
|
||||
|
||||
export function loadApiSettings(): ApiSettings {
|
||||
const storedBaseUrl = localStorage.getItem(`${STORAGE_KEY}.baseUrl`);
|
||||
const storedBaseUrl = loadStoredSetting("baseUrl");
|
||||
const storedApiKey = loadStoredSetting("apiKey");
|
||||
const configuredBaseUrl = storedBaseUrl !== null ? storedBaseUrl : import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
const apiBaseUrl = normalizeApiBaseUrl(configuredBaseUrl);
|
||||
|
||||
@@ -66,6 +87,11 @@ export function loadApiSettings(): ApiSettings {
|
||||
if (storedBaseUrl !== null && storedBaseUrl !== apiBaseUrl) {
|
||||
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, apiBaseUrl);
|
||||
}
|
||||
if (storedApiKey !== null) {
|
||||
localStorage.setItem(`${STORAGE_KEY}.apiKey`, storedApiKey);
|
||||
}
|
||||
clearLegacyStoredSetting("baseUrl");
|
||||
clearLegacyStoredSetting("apiKey");
|
||||
|
||||
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
|
||||
sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`);
|
||||
@@ -73,7 +99,7 @@ export function loadApiSettings(): ApiSettings {
|
||||
return {
|
||||
// Empty base URL means "same origin". In Vite dev, /api is proxied to FastAPI.
|
||||
apiBaseUrl,
|
||||
apiKey: localStorage.getItem(`${STORAGE_KEY}.apiKey`) || "",
|
||||
apiKey: storedApiKey || "",
|
||||
accessToken: ""
|
||||
};
|
||||
}
|
||||
@@ -81,6 +107,8 @@ export function loadApiSettings(): ApiSettings {
|
||||
export function saveApiSettings(settings: ApiSettings): void {
|
||||
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, normalizeApiBaseUrl(settings.apiBaseUrl));
|
||||
localStorage.setItem(`${STORAGE_KEY}.apiKey`, settings.apiKey);
|
||||
clearLegacyStoredSetting("baseUrl");
|
||||
clearLegacyStoredSetting("apiKey");
|
||||
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
|
||||
sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`);
|
||||
}
|
||||
@@ -90,14 +118,29 @@ export function clearAccessToken(): void {
|
||||
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
|
||||
}
|
||||
|
||||
function loadStoredSetting(name: string): string | null {
|
||||
const keys = [STORAGE_KEY, ...LEGACY_STORAGE_KEYS];
|
||||
for (const key of keys) {
|
||||
const value = localStorage.getItem(`${key}.${name}`);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearLegacyStoredSetting(name: string): void {
|
||||
for (const key of LEGACY_STORAGE_KEYS) {
|
||||
localStorage.removeItem(`${key}.${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function readCookie(name: string): string {
|
||||
const prefix = `${encodeURIComponent(name)}=`;
|
||||
return document.cookie
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(prefix))
|
||||
?.slice(prefix.length) ?? "";
|
||||
return document.cookie.
|
||||
split(";").
|
||||
map((part) => part.trim()).
|
||||
find((part) => part.startsWith(prefix))?.
|
||||
slice(prefix.length) ?? "";
|
||||
}
|
||||
|
||||
export function csrfToken(): string {
|
||||
@@ -111,29 +154,29 @@ function isUnsafeMethod(method?: string): boolean {
|
||||
}
|
||||
|
||||
function canReuseSafeRequest(method: string, init?: RequestInit): boolean {
|
||||
return (method === "GET" || method === "HEAD")
|
||||
&& !init?.body
|
||||
&& !init?.signal
|
||||
&& init?.cache !== "no-store"
|
||||
&& init?.cache !== "reload";
|
||||
return (method === "GET" || method === "HEAD") &&
|
||||
!init?.body &&
|
||||
!init?.signal &&
|
||||
init?.cache !== "no-store" &&
|
||||
init?.cache !== "reload";
|
||||
}
|
||||
|
||||
function requestHeadersKey(headers: Headers): string {
|
||||
return [...headers.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join("\n");
|
||||
return [...headers.entries()].
|
||||
sort(([left], [right]) => left.localeCompare(right)).
|
||||
map(([key, value]) => `${key}:${value}`).
|
||||
join("\n");
|
||||
}
|
||||
|
||||
function safeRequestKey(url: string, method: string, headers: Headers, init?: RequestInit): string {
|
||||
return [
|
||||
method,
|
||||
url,
|
||||
init?.credentials ?? "include",
|
||||
init?.mode ?? "",
|
||||
init?.redirect ?? "",
|
||||
requestHeadersKey(headers),
|
||||
].join("\n\n");
|
||||
method,
|
||||
url,
|
||||
init?.credentials ?? "include",
|
||||
init?.mode ?? "",
|
||||
init?.redirect ?? "",
|
||||
requestHeadersKey(headers)].
|
||||
join("\n\n");
|
||||
}
|
||||
|
||||
function pruneRecentSafeRequests(now = Date.now()): void {
|
||||
@@ -167,6 +210,31 @@ function rememberSafeResponse(key: string, value: unknown): void {
|
||||
pruneRecentSafeRequests(now);
|
||||
}
|
||||
|
||||
function conditionalSafeResponse(key: string): ConditionalSafeRequest | undefined {
|
||||
const cached = conditionalSafeRequests.get(key);
|
||||
if (!cached) return undefined;
|
||||
conditionalSafeRequests.delete(key);
|
||||
conditionalSafeRequests.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function rememberConditionalSafeResponse(key: string, etag: string, value: unknown): void {
|
||||
conditionalSafeRequests.delete(key);
|
||||
conditionalSafeRequests.set(key, { etag, value });
|
||||
while (conditionalSafeRequests.size > MAX_CONDITIONAL_SAFE_REQUESTS) {
|
||||
const oldestKey = conditionalSafeRequests.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
conditionalSafeRequests.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSafeRequestCaches(): void {
|
||||
safeRequestGeneration += 1;
|
||||
inFlightSafeRequests.clear();
|
||||
recentSafeRequests.clear();
|
||||
conditionalSafeRequests.clear();
|
||||
}
|
||||
|
||||
export function authHeaders(settings: ApiSettings): Headers {
|
||||
const headers = new Headers();
|
||||
if (settings.accessToken) {
|
||||
@@ -187,13 +255,13 @@ function notifyAuthRequired(path: string): void {
|
||||
const detail: AuthRequiredEventDetail = {
|
||||
path,
|
||||
status: 401,
|
||||
message: "Your session has expired. Sign in again to continue."
|
||||
message: "i18n:govoplan-core.your_session_has_expired_sign_in_again_to_contin.4cc6a1b3"
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent<AuthRequiredEventDetail>(AUTH_REQUIRED_EVENT, { detail }));
|
||||
}
|
||||
|
||||
function authExpiredError(statusText: string): ApiError {
|
||||
return new ApiError(401, statusText || "Unauthorized", "Your session has expired. Sign in again to continue.");
|
||||
return new ApiError(401, statusText || "i18n:govoplan-core.unauthorized.740b8315", "i18n:govoplan-core.your_session_has_expired_sign_in_again_to_contin.4cc6a1b3");
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -214,17 +282,27 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
}
|
||||
|
||||
if (isUnsafeMethod(method)) {
|
||||
safeRequestGeneration += 1;
|
||||
inFlightSafeRequests.clear();
|
||||
recentSafeRequests.clear();
|
||||
clearSafeRequestCaches();
|
||||
}
|
||||
|
||||
const url = apiUrl(settings, path);
|
||||
const fetchInit = { ...init, headers, credentials: init?.credentials ?? "include" };
|
||||
const reusableSafeRequest = canReuseSafeRequest(method, init);
|
||||
const cacheKey = reusableSafeRequest ? safeRequestKey(url, method, headers, init) : null;
|
||||
|
||||
async function runFetch(): Promise<T> {
|
||||
const fetchHeaders = new Headers(headers);
|
||||
const conditional = cacheKey ? conditionalSafeResponse(cacheKey) : undefined;
|
||||
if (conditional && !fetchHeaders.has("If-None-Match")) {
|
||||
fetchHeaders.set("If-None-Match", conditional.etag);
|
||||
}
|
||||
const fetchInit = { ...init, headers: fetchHeaders, credentials: init?.credentials ?? "include" };
|
||||
const response = await fetch(url, fetchInit);
|
||||
|
||||
if (response.status === 304 && cacheKey && conditional) {
|
||||
rememberSafeResponse(cacheKey, conditional.value);
|
||||
return conditional.value as T;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
|
||||
@@ -235,22 +313,31 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
if (cacheKey) conditionalSafeRequests.delete(cacheKey);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
let value: T;
|
||||
if (!contentType.includes("application/json")) {
|
||||
return (await response.text()) as T;
|
||||
value = (await response.text()) as T;
|
||||
} else {
|
||||
value = (await response.json()) as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
const etag = response.headers.get("etag");
|
||||
if (cacheKey && etag) {
|
||||
rememberConditionalSafeResponse(cacheKey, etag, value);
|
||||
} else if (cacheKey) {
|
||||
conditionalSafeRequests.delete(cacheKey);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!canReuseSafeRequest(method, init)) {
|
||||
if (!reusableSafeRequest || !cacheKey) {
|
||||
return runFetch();
|
||||
}
|
||||
|
||||
const cacheKey = safeRequestKey(url, method, headers, init);
|
||||
const requestGeneration = safeRequestGeneration;
|
||||
const recent = recentSafeResponse(cacheKey);
|
||||
if (recent !== undefined) {
|
||||
@@ -262,18 +349,18 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
return existing as Promise<T>;
|
||||
}
|
||||
|
||||
const request = runFetch()
|
||||
.then((value) => {
|
||||
if (requestGeneration === safeRequestGeneration) {
|
||||
rememberSafeResponse(cacheKey, value);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightSafeRequests.get(cacheKey) === request) {
|
||||
inFlightSafeRequests.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
const request = runFetch().
|
||||
then((value) => {
|
||||
if (requestGeneration === safeRequestGeneration) {
|
||||
rememberSafeResponse(cacheKey, value);
|
||||
}
|
||||
return value;
|
||||
}).
|
||||
finally(() => {
|
||||
if (inFlightSafeRequests.get(cacheKey) === request) {
|
||||
inFlightSafeRequests.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
inFlightSafeRequests.set(cacheKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,15 @@ export type PlatformStatusResponse = {
|
||||
enabled: boolean;
|
||||
message?: string | null;
|
||||
};
|
||||
i18n?: {
|
||||
available_languages: Array<{
|
||||
code: string;
|
||||
label: string;
|
||||
native_label?: string | null;
|
||||
}>;
|
||||
enabled_languages: string[];
|
||||
default_language: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PlatformPermission = {
|
||||
|
||||
@@ -39,22 +39,22 @@ export function ResourceAccessBoundary({ probe, resetKey, fallback, children }:
|
||||
let cancelled = false;
|
||||
setState("checking");
|
||||
setMessage("");
|
||||
probe()
|
||||
.then(() => { if (!cancelled) setState("allowed"); })
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
if (isApiError(error, 403, 404)) {
|
||||
setState("denied");
|
||||
return;
|
||||
}
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
setState("error");
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
probe().
|
||||
then(() => {if (!cancelled) setState("allowed");}).
|
||||
catch((error) => {
|
||||
if (cancelled) return;
|
||||
if (isApiError(error, 403, 404)) {
|
||||
setState("denied");
|
||||
return;
|
||||
}
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
setState("error");
|
||||
});
|
||||
return () => {cancelled = true;};
|
||||
}, [probe, resetKey]);
|
||||
|
||||
if (state === "denied") return <Navigate to={fallback} replace />;
|
||||
if (state === "error") return <div className="content-pad"><DismissibleAlert tone="danger" dismissible={false}>{message || "The resource could not be loaded."}</DismissibleAlert></div>;
|
||||
if (state === "checking") return <div className="content-pad"><p className="muted">Checking access…</p></div>;
|
||||
if (state === "error") return <div className="content-pad"><DismissibleAlert tone="danger" dismissible={false}>{message || "i18n:govoplan-core.the_resource_could_not_be_loaded.0d1b6cbf"}</DismissibleAlert></div>;
|
||||
if (state === "checking") return <div className="content-pad"><p className="muted">i18n:govoplan-core.checking_access.c5108c02</p></div>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
}
|
||||
64
webui/src/components/ActionBlockerHint.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import AdvancedOptionsPanel from "./AdvancedOptionsPanel";
|
||||
|
||||
export type ActionBlockerReason = {
|
||||
summary: ReactNode;
|
||||
details?: ReactNode;
|
||||
requiredAction?: ReactNode;
|
||||
actor?: ReactNode;
|
||||
target?: ReactNode;
|
||||
technicalDetails?: ReactNode;
|
||||
};
|
||||
|
||||
type ActionBlockerHintProps = {
|
||||
reason: ActionBlockerReason;
|
||||
tone?: "info" | "warning" | "danger";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function joinClasses(...classes: Array<string | undefined | false>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function ActionBlockerHint({ reason, tone = "warning", className = "" }: ActionBlockerHintProps) {
|
||||
const Icon = tone === "info" ? Info : AlertTriangle;
|
||||
const hasActionRows = Boolean(reason.requiredAction || reason.actor || reason.target);
|
||||
|
||||
return (
|
||||
<section className={joinClasses("action-blocker-hint", `tone-${tone}`, className)}>
|
||||
<Icon className="action-blocker-icon" size={18} aria-hidden="true" />
|
||||
<div className="action-blocker-copy">
|
||||
<strong>{reason.summary}</strong>
|
||||
{reason.details && <p>{reason.details}</p>}
|
||||
{hasActionRows && (
|
||||
<dl>
|
||||
{reason.requiredAction && (
|
||||
<>
|
||||
<dt>Required action</dt>
|
||||
<dd>{reason.requiredAction}</dd>
|
||||
</>
|
||||
)}
|
||||
{reason.actor && (
|
||||
<>
|
||||
<dt>Who can fix it</dt>
|
||||
<dd>{reason.actor}</dd>
|
||||
</>
|
||||
)}
|
||||
{reason.target && (
|
||||
<>
|
||||
<dt>Where to go</dt>
|
||||
<dd>{reason.target}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
{reason.technicalDetails && (
|
||||
<AdvancedOptionsPanel title="Technical details" className="action-blocker-technical">
|
||||
<div>{reason.technicalDetails}</div>
|
||||
</AdvancedOptionsPanel>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
33
webui/src/components/AdvancedOptionsPanel.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type AdvancedOptionsPanelProps = {
|
||||
title?: ReactNode;
|
||||
summary?: ReactNode;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function joinClasses(...classes: Array<string | undefined | false>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function AdvancedOptionsPanel({
|
||||
title = "Advanced options",
|
||||
summary,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
className = ""
|
||||
}: AdvancedOptionsPanelProps) {
|
||||
return (
|
||||
<details className={joinClasses("advanced-options-panel", className)} open={defaultOpen}>
|
||||
<summary>
|
||||
<span>{title}</span>
|
||||
<ChevronDown size={16} aria-hidden="true" />
|
||||
</summary>
|
||||
{summary && <p className="advanced-options-summary">{summary}</p>}
|
||||
<div className="advanced-options-body">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type CardProps = {
|
||||
title?: ReactNode;
|
||||
@@ -33,17 +34,19 @@ function writeCollapseState(storageKey: string | null, collapsed: boolean): void
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, collapsed ? "1" : "0");
|
||||
} catch {
|
||||
|
||||
// localStorage may be unavailable in private or restricted contexts.
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true }: CardProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title);
|
||||
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
|
||||
const collapsed = collapseState.storageKey === storageKey ? collapseState.collapsed : readCollapseState(storageKey);
|
||||
const hasHeader = Boolean(title || actions || collapsible);
|
||||
const body = <div className="card-body">{children}</div>;
|
||||
const shouldRenderBody = !collapsible || !collapsed;
|
||||
const collapseLabel = translateText(collapsed ? "i18n:govoplan-core.show_content.0528d8d2" : "i18n:govoplan-core.show_header_only.24afefca");
|
||||
|
||||
useEffect(() => {
|
||||
setCollapseState({ storageKey, collapsed: readCollapseState(storageKey) });
|
||||
@@ -57,29 +60,29 @@ export default function Card({ title, children, actions, collapsible = false, co
|
||||
|
||||
return (
|
||||
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}>
|
||||
{hasHeader && (
|
||||
<header className="card-header">
|
||||
{title && (typeof title === "string" ? <h2>{title}</h2> : <div className="card-title-node">{title}</div>)}
|
||||
{(actions || collapsible) && (
|
||||
<div className="card-actions">
|
||||
{hasHeader &&
|
||||
<header className="card-header">
|
||||
{title && (typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>)}
|
||||
{(actions || collapsible) &&
|
||||
<div className="card-actions">
|
||||
{actions}
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
className="card-collapse-toggle"
|
||||
aria-label={collapsed ? "Show content" : "Show header only"}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Show content" : "Show header only"}
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
{collapsible &&
|
||||
<button
|
||||
type="button"
|
||||
className="card-collapse-toggle"
|
||||
aria-label={collapseLabel}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapseLabel}
|
||||
onClick={toggleCollapsed}>
|
||||
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</header>
|
||||
)}
|
||||
}
|
||||
{shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}</div> : body)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
</section>);
|
||||
|
||||
}
|
||||
105
webui/src/components/ColorPickerField.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { i18nMessage } from "../i18n/LanguageContext";import { Palette } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type InputHTMLAttributes } from "react";
|
||||
|
||||
type ColorPickerFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
swatches?: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_SWATCHES = [
|
||||
"#4f8cff", "#38a169", "#d69e2e", "#e53e3e", "#805ad5", "#319795",
|
||||
"#dd6b20", "#718096", "#1f2937", "#f59e0b", "#10b981", "#ef4444"];
|
||||
|
||||
|
||||
const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
function normalizeColor(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed}`;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function useOutsideClose(open: boolean, onClose: () => void) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) return;
|
||||
onClose();
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
export default function ColorPickerField({
|
||||
value,
|
||||
onChange,
|
||||
swatches = DEFAULT_SWATCHES,
|
||||
disabled,
|
||||
className = "",
|
||||
placeholder = "#4f8cff",
|
||||
...props
|
||||
}: ColorPickerFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useOutsideClose(open, () => setOpen(false));
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const normalizedValue = normalizeColor(value || "");
|
||||
const previewColor = HEX_PATTERN.test(normalizedValue) ? normalizedValue : "transparent";
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.setCustomValidity(normalizedValue && !HEX_PATTERN.test(normalizedValue) ? "i18n:govoplan-core.use_a_six_digit_hex_color_for_example_4f8cff.d74f4ad2" : "");
|
||||
}, [normalizedValue]);
|
||||
|
||||
function selectColor(nextColor: string) {
|
||||
onChange(nextColor);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`color-picker-field ${className}`.trim()}>
|
||||
<span className="color-picker-preview" style={{ backgroundColor: previewColor }} aria-hidden="true" />
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="text"
|
||||
pattern="#[0-9a-fA-F]{6}"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(normalizeColor(event.target.value))} />
|
||||
|
||||
<button type="button" className="color-picker-trigger" onClick={() => !disabled && setOpen((current) => !current)} disabled={disabled} aria-label="i18n:govoplan-core.choose_color.09b969bb">
|
||||
<Palette size={16} />
|
||||
</button>
|
||||
{open &&
|
||||
<div className="color-picker-popover" role="dialog" aria-label="i18n:govoplan-core.color_picker.91dee962">
|
||||
<div className="color-picker-grid">
|
||||
{swatches.map((swatch) =>
|
||||
<button
|
||||
key={swatch}
|
||||
type="button"
|
||||
className={swatch.toLowerCase() === normalizedValue.toLowerCase() ? "is-selected" : ""}
|
||||
style={{ backgroundColor: swatch }}
|
||||
onClick={() => selectColor(swatch)}
|
||||
aria-label={i18nMessage("i18n:govoplan-core.use_value.bac38fc3", { value0: swatch })} />
|
||||
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Button from "./Button";
|
||||
import Dialog from "./Dialog";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export type ConfirmDialogTone = "default" | "danger";
|
||||
|
||||
@@ -19,13 +20,14 @@ export default function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
confirmLabel = "i18n:govoplan-core.confirm.04a21221",
|
||||
cancelLabel = "i18n:govoplan-core.cancel.77dfd213",
|
||||
tone = "default",
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -37,14 +39,14 @@ export default function ConfirmDialog({
|
||||
closeDisabled={busy}
|
||||
showCloseButton
|
||||
onClose={onCancel}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
|
||||
<Button variant={tone === "danger" ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? "Working…" : confirmLabel}</Button>
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={busy}>{translateText(cancelLabel)}</Button>
|
||||
<Button variant={tone === "danger" ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? translateText("i18n:govoplan-core.working.13b7bfca") : translateText(confirmLabel)}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p>{message}</p>
|
||||
</Dialog>
|
||||
);
|
||||
}>
|
||||
|
||||
<p>{translateText(message)}</p>
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
|
||||
84
webui/src/components/ConnectionTree.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
export type ConnectionTreeColumn<T> = {
|
||||
id: string;
|
||||
header: ReactNode;
|
||||
width?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
render: (row: T, depth: number) => ReactNode;
|
||||
};
|
||||
|
||||
export type ConnectionTreeProps<T> = {
|
||||
rows: T[];
|
||||
columns: ConnectionTreeColumn<T>[];
|
||||
getRowKey: (row: T) => string;
|
||||
getChildren?: (row: T) => T[];
|
||||
renderActions?: (row: T, depth: number) => ReactNode;
|
||||
emptyText?: ReactNode;
|
||||
className?: string;
|
||||
rowClassName?: (row: T, depth: number) => string;
|
||||
};
|
||||
|
||||
type FlatRow<T> = {
|
||||
row: T;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
export default function ConnectionTree<T>({
|
||||
rows,
|
||||
columns,
|
||||
getRowKey,
|
||||
getChildren,
|
||||
renderActions,
|
||||
emptyText = "i18n:govoplan-core.no_entries_configured.48e7b97c",
|
||||
className = "",
|
||||
rowClassName
|
||||
}: ConnectionTreeProps<T>) {
|
||||
const flatRows = flattenRows(rows, getChildren);
|
||||
const gridTemplateColumns = `${columns.map((column) => column.width || "minmax(0, 1fr)").join(" ")} ${renderActions ? "var(--connection-tree-actions-column, 196px)" : ""}`.trim();
|
||||
|
||||
return (
|
||||
<div className={`connection-tree ${className}`.trim()}>
|
||||
<div className="connection-tree-header" style={{ gridTemplateColumns }}>
|
||||
{columns.map((column) =>
|
||||
<div className={`connection-tree-cell ${column.align ? `align-${column.align}` : ""}`.trim()} key={column.id}>
|
||||
{column.header}
|
||||
</div>
|
||||
)}
|
||||
{renderActions && <div className="connection-tree-cell align-right">i18n:govoplan-core.actions.c3cd636a</div>}
|
||||
</div>
|
||||
{flatRows.length === 0 && <div className="connection-tree-empty">{emptyText}</div>}
|
||||
{flatRows.map(({ row, depth }) => {
|
||||
const className = ["connection-tree-row", depth > 0 ? "is-child" : "is-parent", rowClassName?.(row, depth) || ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div className={className} style={{ gridTemplateColumns }} key={getRowKey(row)}>
|
||||
{columns.map((column, index) =>
|
||||
<div
|
||||
className={`connection-tree-cell ${column.align ? `align-${column.align}` : ""} ${index === 0 ? "connection-tree-primary-cell" : ""}`.trim()}
|
||||
key={column.id}>
|
||||
{index === 0 ?
|
||||
<div className="connection-tree-primary-content" style={{ "--connection-tree-depth": String(depth) } as CSSProperties}>
|
||||
{column.render(row, depth)}
|
||||
</div> :
|
||||
column.render(row, depth)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{renderActions && <div className="connection-tree-cell align-right connection-tree-actions">{renderActions(row, depth)}</div>}
|
||||
</div>);
|
||||
|
||||
})}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function flattenRows<T>(rows: T[], getChildren?: (row: T) => T[]): FlatRow<T>[] {
|
||||
const result: FlatRow<T>[] = [];
|
||||
for (const row of rows) {
|
||||
result.push({ row, depth: 0 });
|
||||
for (const child of getChildren?.(row) ?? []) {
|
||||
result.push({ row: child, depth: 1 });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
231
webui/src/components/DateTimeField.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react";
|
||||
|
||||
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
min?: string;
|
||||
max?: string;
|
||||
};
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const TIME_PATTERN = /^\d{2}:\d{2}$/;
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function dateString(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function parseDate(value: string): Date | null {
|
||||
if (!DATE_PATTERN.test(value)) return null;
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
function monthLabel(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function clampDateValue(value: string, min?: string, max?: string): string {
|
||||
if (min && value < min) return min;
|
||||
if (max && value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
function datePartsFromDateTime(value: string): {date: string;time: string;} {
|
||||
if (!value) return { date: "", time: "" };
|
||||
const [date = "", time = ""] = value.split("T", 2);
|
||||
return { date, time: time.slice(0, 5) };
|
||||
}
|
||||
|
||||
function combineDateTime(date: string, time: string): string {
|
||||
if (!date && !time) return "";
|
||||
return `${date || dateString(new Date())}T${time || "00:00"}`;
|
||||
}
|
||||
|
||||
function useOutsideClose(open: boolean, onClose: () => void) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) return;
|
||||
onClose();
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", ...props }: BaseProps) {
|
||||
const selectedDate = parseDate(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
|
||||
const rootRef = useOutsideClose(open, () => setOpen(false));
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate) setVisibleMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
let message = "";
|
||||
if (value && !parseDate(value)) message = "i18n:govoplan-core.use_yyyy_mm_dd.406d2e4d";else
|
||||
if (value && min && value < min) message = `Use ${min} or later.`;else
|
||||
if (value && max && value > max) message = `Use ${max} or earlier.`;
|
||||
input.setCustomValidity(message);
|
||||
}, [value, min, max]);
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const first = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1);
|
||||
const start = new Date(first);
|
||||
const weekday = (first.getDay() + 6) % 7;
|
||||
start.setDate(first.getDate() - weekday);
|
||||
return Array.from({ length: 42 }, (_unused, index) => {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + index);
|
||||
return date;
|
||||
});
|
||||
}, [visibleMonth]);
|
||||
|
||||
function selectDate(nextDate: Date) {
|
||||
const nextValue = clampDateValue(dateString(nextDate), min, max);
|
||||
onChange(nextValue);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function moveMonth(delta: number) {
|
||||
setVisibleMonth((current) => new Date(current.getFullYear(), current.getMonth() + delta, 1));
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`date-field ${className}`.trim()}>
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="\d{4}-\d{2}-\d{2}"
|
||||
value={value}
|
||||
min={undefined}
|
||||
max={undefined}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)} />
|
||||
|
||||
<button type="button" className="date-field-trigger" onClick={() => !disabled && setOpen((current) => !current)} disabled={disabled} aria-label="i18n:govoplan-core.choose_date.e7877fd9">
|
||||
<CalendarDays size={16} />
|
||||
</button>
|
||||
{open &&
|
||||
<div className="date-field-popover" role="dialog" aria-label="i18n:govoplan-core.calendar_picker.18756418">
|
||||
<div className="date-field-monthbar">
|
||||
<button type="button" onClick={() => moveMonth(-1)} aria-label="i18n:govoplan-core.previous_month.46a29921"><ChevronLeft size={16} /></button>
|
||||
<strong>{monthLabel(visibleMonth)}</strong>
|
||||
<button type="button" onClick={() => moveMonth(1)} aria-label="i18n:govoplan-core.next_month.8abf7cf1"><ChevronRight size={16} /></button>
|
||||
</div>
|
||||
<div className="date-field-weekdays" aria-hidden="true">
|
||||
{["i18n:govoplan-core.mo.91e885d8", "i18n:govoplan-core.tu.b44892b7", "i18n:govoplan-core.we.a24ae9fa", "i18n:govoplan-core.th.6929e765", "i18n:govoplan-core.fr.aa77f314", "i18n:govoplan-core.sa.50cf95ce", "i18n:govoplan-core.su.25bfc51c"].map((label) => <span key={label}>{label}</span>)}
|
||||
</div>
|
||||
<div className="date-field-grid">
|
||||
{weeks.map((day) => {
|
||||
const dayValue = dateString(day);
|
||||
const inMonth = day.getMonth() === visibleMonth.getMonth();
|
||||
const isSelected = dayValue === value;
|
||||
const unavailable = Boolean(min && dayValue < min || max && dayValue > max);
|
||||
return (
|
||||
<button
|
||||
key={dayValue}
|
||||
type="button"
|
||||
className={[inMonth ? "" : "is-muted", isSelected ? "is-selected" : ""].filter(Boolean).join(" ")}
|
||||
onClick={() => selectDate(day)}
|
||||
disabled={unavailable}>
|
||||
|
||||
{day.getDate()}
|
||||
</button>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", ...props }: BaseProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
let message = "";
|
||||
if (value && !TIME_PATTERN.test(value)) message = "i18n:govoplan-core.use_hh_mm.e995be3f";else
|
||||
if (value && min && value < min) message = `Use ${min} or later.`;else
|
||||
if (value && max && value > max) message = `Use ${max} or earlier.`;
|
||||
input.setCustomValidity(message);
|
||||
}, [value, min, max]);
|
||||
|
||||
return (
|
||||
<div className={`time-field ${className}`.trim()}>
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="\d{2}:\d{2}"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)} />
|
||||
|
||||
<Clock className="time-field-icon" size={16} aria-hidden="true" />
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function DateTimeField({ value, onChange, min, max, disabled, className = "", ...props }: BaseProps) {
|
||||
const parts = datePartsFromDateTime(value);
|
||||
const minParts = datePartsFromDateTime(min || "");
|
||||
const maxParts = datePartsFromDateTime(max || "");
|
||||
|
||||
function changeDate(nextDate: string) {
|
||||
onChange(combineDateTime(nextDate, parts.time));
|
||||
}
|
||||
|
||||
function changeTime(nextTime: string) {
|
||||
onChange(combineDateTime(parts.date, nextTime));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`date-time-field ${className}`.trim()}>
|
||||
<DateField
|
||||
{...props}
|
||||
value={parts.date}
|
||||
min={minParts.date}
|
||||
max={maxParts.date}
|
||||
disabled={disabled}
|
||||
onChange={changeDate} />
|
||||
|
||||
<TimeField
|
||||
value={parts.time}
|
||||
min={minParts.date && minParts.date === parts.date ? minParts.time : undefined}
|
||||
max={maxParts.date && maxParts.date === parts.date ? maxParts.time : undefined}
|
||||
disabled={disabled}
|
||||
onChange={changeTime} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export default DateField;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useId, type ReactNode } from "react";
|
||||
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export type DialogProps = {
|
||||
open: boolean;
|
||||
@@ -31,7 +32,7 @@ export default function Dialog({
|
||||
footer,
|
||||
role = "dialog",
|
||||
ariaDescribedBy,
|
||||
closeLabel = "Close",
|
||||
closeLabel = "i18n:govoplan-core.close.bbfa773e",
|
||||
closeOnBackdrop = true,
|
||||
showCloseButton = true,
|
||||
closeDisabled = false,
|
||||
@@ -45,6 +46,11 @@ export default function Dialog({
|
||||
}: DialogProps) {
|
||||
const titleId = useId();
|
||||
const canClose = Boolean(onClose) && !closeDisabled;
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedTitle = translateReactNode(title, translateText);
|
||||
const renderedChildren = translateReactNode(children, translateText);
|
||||
const renderedFooter = translateReactNode(footer, translateText);
|
||||
const translatedCloseLabel = translateText(closeLabel);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canClose) return undefined;
|
||||
@@ -75,21 +81,21 @@ export default function Dialog({
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
<div className={joinClasses("dialog-header", headerClassName)}>
|
||||
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{title}</h2>
|
||||
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{renderedTitle}</h2>
|
||||
{showCloseButton && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-close"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
aria-label={translatedCloseLabel}
|
||||
disabled={closeDisabled}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={joinClasses("dialog-body", bodyClassName)}>{children}</div>
|
||||
{footer && <div className={joinClasses("dialog-footer", footerClassName)}>{footer}</div>}
|
||||
<div className={joinClasses("dialog-body", bodyClassName)}>{renderedChildren}</div>
|
||||
{footer && <div className={joinClasses("dialog-footer", footerClassName)}>{renderedFooter}</div>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type AlertTone = "success" | "info" | "warning" | "danger";
|
||||
|
||||
@@ -17,20 +18,24 @@ type DismissibleAlertProps = {
|
||||
|
||||
let floatingAlertRoot: HTMLElement | null = null;
|
||||
|
||||
function getFloatingAlertRoot(): HTMLElement | null {
|
||||
function getFloatingAlertRoot(ariaLabel: string): HTMLElement | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
if (floatingAlertRoot?.isConnected) return floatingAlertRoot;
|
||||
if (floatingAlertRoot?.isConnected) {
|
||||
floatingAlertRoot.setAttribute("aria-label", ariaLabel);
|
||||
return floatingAlertRoot;
|
||||
}
|
||||
|
||||
const existing = document.getElementById("app-floating-alerts");
|
||||
if (existing) {
|
||||
floatingAlertRoot = existing;
|
||||
floatingAlertRoot.setAttribute("aria-label", ariaLabel);
|
||||
return floatingAlertRoot;
|
||||
}
|
||||
|
||||
floatingAlertRoot = document.createElement("div");
|
||||
floatingAlertRoot.id = "app-floating-alerts";
|
||||
floatingAlertRoot.className = "alert-floating-stack";
|
||||
floatingAlertRoot.setAttribute("aria-label", "Application notices");
|
||||
floatingAlertRoot.setAttribute("aria-label", ariaLabel);
|
||||
document.body.appendChild(floatingAlertRoot);
|
||||
return floatingAlertRoot;
|
||||
}
|
||||
@@ -68,6 +73,7 @@ export default function DismissibleAlert({
|
||||
resetKey,
|
||||
dismissStorageKey
|
||||
}: DismissibleAlertProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const storageKey = resolveDismissStorageKey(dismissStorageKey, resetKey);
|
||||
const [alertState, setAlertState] = useState(() => ({ storageKey, visible: !readDismissed(storageKey) }));
|
||||
const visible = alertState.storageKey === storageKey ? alertState.visible : !readDismissed(storageKey);
|
||||
@@ -77,6 +83,8 @@ export default function DismissibleAlert({
|
||||
}, [storageKey, resetKey, children]);
|
||||
|
||||
if (!visible) return null;
|
||||
const renderedChildren = translateReactNode(children, translateText);
|
||||
const floatingLabel = translateText("i18n:govoplan-core.application_notices");
|
||||
|
||||
function dismiss() {
|
||||
writeDismissed(storageKey);
|
||||
@@ -90,9 +98,9 @@ export default function DismissibleAlert({
|
||||
role={role}
|
||||
aria-live={role === "alert" ? "assertive" : "polite"}
|
||||
>
|
||||
<div className="alert-message">{children}</div>
|
||||
<div className="alert-message">{renderedChildren}</div>
|
||||
{dismissible && (
|
||||
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={dismiss}>
|
||||
<button type="button" className="alert-dismiss" aria-label={translateText("i18n:govoplan-core.dismiss_notice.ce08c9ad")} onClick={dismiss}>
|
||||
<X size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
@@ -100,6 +108,6 @@ export default function DismissibleAlert({
|
||||
);
|
||||
|
||||
if (!floating) return alert;
|
||||
const root = getFloatingAlertRoot();
|
||||
const root = getFloatingAlertRoot(floatingLabel);
|
||||
return root ? createPortal(alert, root) : alert;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Folder, FolderOpen } from "lucide-react";
|
||||
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
import { i18nMessage } from "../i18n/LanguageContext";
|
||||
|
||||
export type ExplorerTreeNodeContext = {
|
||||
depth: number;
|
||||
@@ -9,7 +10,7 @@ export type ExplorerTreeNodeContext = {
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
type ExplorerTreeProps<T> = {
|
||||
export type ExplorerTreeProps<T> = {
|
||||
nodes: T[];
|
||||
getNodeId: (node: T) => string;
|
||||
getNodeLabel: (node: T) => string;
|
||||
@@ -21,6 +22,11 @@ type ExplorerTreeProps<T> = {
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
className?: string;
|
||||
childrenBaseClassName?: string;
|
||||
nodeContainerClassName?: string;
|
||||
nodeWrapBaseClassName?: string;
|
||||
toggleBaseClassName?: string;
|
||||
nodeButtonBaseClassName?: string;
|
||||
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
||||
@@ -47,6 +53,11 @@ export default function ExplorerTree<T>({
|
||||
disabled = false,
|
||||
depth = 1,
|
||||
className = "",
|
||||
childrenBaseClassName = "explorer-tree-children file-tree-children",
|
||||
nodeContainerClassName = "",
|
||||
nodeWrapBaseClassName = "explorer-tree-node-wrap file-tree-node-wrap",
|
||||
toggleBaseClassName = "explorer-tree-toggle file-tree-toggle",
|
||||
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
|
||||
renderToggleIcon,
|
||||
renderNodeContent,
|
||||
getNodeWrapClassName,
|
||||
@@ -63,7 +74,7 @@ export default function ExplorerTree<T>({
|
||||
if (nodes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={["explorer-tree-children", "file-tree-children", className].filter(Boolean).join(" ")}>
|
||||
<div className={[childrenBaseClassName, className].filter(Boolean).join(" ")}>
|
||||
{nodes.map((node) => {
|
||||
const nodeId = getNodeId(node);
|
||||
const children = getNodeChildren(node);
|
||||
@@ -74,14 +85,13 @@ export default function ExplorerTree<T>({
|
||||
const draggable = getNodeDraggable?.(node, context) ?? false;
|
||||
|
||||
return (
|
||||
<div key={nodeId}>
|
||||
<div key={nodeId} className={nodeContainerClassName}>
|
||||
<div
|
||||
className={[
|
||||
"explorer-tree-node-wrap",
|
||||
"file-tree-node-wrap",
|
||||
active ? "is-active" : "",
|
||||
getNodeWrapClassName?.(node, context) ?? ""
|
||||
].filter(Boolean).join(" ")}
|
||||
nodeWrapBaseClassName,
|
||||
active ? "is-active" : "",
|
||||
getNodeWrapClassName?.(node, context) ?? ""].
|
||||
filter(Boolean).join(" ")}
|
||||
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
||||
draggable={draggable && !disabled}
|
||||
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
|
||||
@@ -89,59 +99,64 @@ export default function ExplorerTree<T>({
|
||||
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
|
||||
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
|
||||
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
|
||||
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
|
||||
>
|
||||
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="explorer-tree-toggle file-tree-toggle"
|
||||
className={toggleBaseClassName}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (hasChildren) onToggle(node, context);
|
||||
}}
|
||||
disabled={disabled || !hasChildren}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${getNodeLabel(node)}`}
|
||||
aria-expanded={hasChildren ? expanded : undefined}
|
||||
>
|
||||
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", { value0: expanded ? "i18n:govoplan-core.collapse.9cf188d3" : "i18n:govoplan-core.expand.9869e506", value1: getNodeLabel(node) })}
|
||||
aria-expanded={hasChildren ? expanded : undefined}>
|
||||
|
||||
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={["explorer-tree-node", "file-tree-node", getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
||||
className={[nodeButtonBaseClassName, getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
||||
onClick={() => onOpen(node, context)}
|
||||
disabled={disabled}
|
||||
>
|
||||
disabled={disabled}>
|
||||
|
||||
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<ExplorerTree
|
||||
nodes={children}
|
||||
getNodeId={getNodeId}
|
||||
getNodeLabel={getNodeLabel}
|
||||
getNodeChildren={getNodeChildren}
|
||||
activeId={activeId}
|
||||
expandedIds={expandedIds}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
disabled={disabled}
|
||||
depth={depth + 1}
|
||||
renderToggleIcon={renderToggleIcon}
|
||||
renderNodeContent={renderNodeContent}
|
||||
getNodeWrapClassName={getNodeWrapClassName}
|
||||
getNodeWrapStyle={getNodeWrapStyle}
|
||||
getNodeButtonClassName={getNodeButtonClassName}
|
||||
getNodeDraggable={getNodeDraggable}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{hasChildren && expanded &&
|
||||
<ExplorerTree
|
||||
nodes={children}
|
||||
getNodeId={getNodeId}
|
||||
getNodeLabel={getNodeLabel}
|
||||
getNodeChildren={getNodeChildren}
|
||||
activeId={activeId}
|
||||
expandedIds={expandedIds}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
disabled={disabled}
|
||||
depth={depth + 1}
|
||||
childrenBaseClassName={childrenBaseClassName}
|
||||
nodeContainerClassName={nodeContainerClassName}
|
||||
nodeWrapBaseClassName={nodeWrapBaseClassName}
|
||||
toggleBaseClassName={toggleBaseClassName}
|
||||
nodeButtonBaseClassName={nodeButtonBaseClassName}
|
||||
renderToggleIcon={renderToggleIcon}
|
||||
renderNodeContent={renderNodeContent}
|
||||
getNodeWrapClassName={getNodeWrapClassName}
|
||||
getNodeWrapStyle={getNodeWrapStyle}
|
||||
getNodeButtonClassName={getNodeButtonClassName}
|
||||
getNodeDraggable={getNodeDraggable}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { UploadCloud } from "lucide-react";
|
||||
|
||||
export type FileDropZoneProps = {
|
||||
@@ -23,13 +23,13 @@ export default function FileDropZone({
|
||||
disabled = false,
|
||||
busy = false,
|
||||
progress = null,
|
||||
label = "Drop files here",
|
||||
actionLabel = "or click to select files",
|
||||
busyLabel = "Uploading files",
|
||||
label = "i18n:govoplan-core.drop_files_here.77348907",
|
||||
actionLabel = "i18n:govoplan-core.or_click_to_select_files.91b05dc1",
|
||||
busyLabel = "i18n:govoplan-core.uploading_files.6536791d",
|
||||
progressLabel,
|
||||
note,
|
||||
className = "",
|
||||
inputLabel = "Drop files here or click to select files",
|
||||
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
|
||||
onFiles
|
||||
}: FileDropZoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -79,25 +79,25 @@ export default function FileDropZone({
|
||||
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" />
|
||||
)}
|
||||
}}>
|
||||
|
||||
{showProgress ?
|
||||
<span
|
||||
className={`file-drop-progress ${progressValue === null ? "is-indeterminate" : ""}`.trim()}
|
||||
role="progressbar"
|
||||
aria-label="i18n:govoplan-core.upload_progress.927d75fe"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={roundedProgress ?? undefined}
|
||||
style={progressStyle}>
|
||||
|
||||
<span>{roundedProgress === null ? "" : i18nMessage("i18n:govoplan-core.value.8c5f2c0e", { value0: roundedProgress })}</span>
|
||||
</span> :
|
||||
|
||||
<UploadCloud size={28} aria-hidden="true" />
|
||||
}
|
||||
<strong>{showProgress ? busyLabel : label}</strong>
|
||||
<span>{showProgress ? progressLabel ?? (roundedProgress === null ? "Uploading…" : `${roundedProgress}% uploaded`) : actionLabel}</span>
|
||||
<span>{showProgress ? progressLabel ?? (roundedProgress === null ? "i18n:govoplan-core.uploading.d921a79a" : i18nMessage("i18n:govoplan-core.value_uploaded.2c4bcc60", { value0: roundedProgress })) : actionLabel}</span>
|
||||
{note && <span className="muted small-text">{note}</span>}
|
||||
</div>
|
||||
<input
|
||||
@@ -106,8 +106,8 @@ export default function FileDropZone({
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
hidden
|
||||
onChange={(event) => event.target.files && void handleFiles(event.target.files)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
onChange={(event) => event.target.files && void handleFiles(event.target.files)} />
|
||||
|
||||
</>);
|
||||
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import FieldLabel from "./help/FieldLabel";
|
||||
import { helpForFieldLabel } from "../utils/fieldHelp";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export default function FormField({ label, help, children }: { label: ReactNode; help?: ReactNode; children: ReactNode }) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||
return (
|
||||
<label className="form-field">
|
||||
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)}>{label}</FieldLabel>
|
||||
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
53
webui/src/components/GuidedConfigDialog.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { WizardStep } from "../types";
|
||||
import Dialog, { type DialogProps } from "./Dialog";
|
||||
import Stepper from "./Stepper";
|
||||
|
||||
export type GuidedConfigDialogProps = Omit<DialogProps, "children"> & {
|
||||
steps: WizardStep[];
|
||||
activeStep: string;
|
||||
onStepChange: (stepId: string) => void;
|
||||
intro?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function joinClasses(...classes: Array<string | undefined | false>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function GuidedConfigDialog({
|
||||
steps,
|
||||
activeStep,
|
||||
onStepChange,
|
||||
intro,
|
||||
children,
|
||||
className = "",
|
||||
bodyClassName = "",
|
||||
...dialogProps
|
||||
}: GuidedConfigDialogProps) {
|
||||
const active = steps.find((step) => step.id === activeStep) ?? steps[0];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...dialogProps}
|
||||
className={joinClasses("guided-config-dialog", className)}
|
||||
bodyClassName={joinClasses("guided-config-dialog-body", bodyClassName)}
|
||||
>
|
||||
<div className="guided-config-shell">
|
||||
<aside className="guided-config-sidebar" aria-label="Setup steps">
|
||||
<Stepper steps={steps} activeStep={active?.id ?? activeStep} onSelect={onStepChange} />
|
||||
</aside>
|
||||
<section className="guided-config-content">
|
||||
{intro && <div className="guided-config-intro">{intro}</div>}
|
||||
{active && (
|
||||
<header className="guided-config-step-heading">
|
||||
<h3>{active.label}</h3>
|
||||
{active.description && <p>{active.description}</p>}
|
||||
</header>
|
||||
)}
|
||||
<div className="guided-config-step-body">{children}</div>
|
||||
</section>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
42
webui/src/components/GuidedReviewList.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type GuidedReviewItem = {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
detail?: ReactNode;
|
||||
tone?: "neutral" | "success" | "warning" | "danger";
|
||||
};
|
||||
|
||||
type GuidedReviewListProps = {
|
||||
items: GuidedReviewItem[];
|
||||
emptyText?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function joinClasses(...classes: Array<string | undefined | false>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function GuidedReviewList({
|
||||
items,
|
||||
emptyText = "Nothing to review.",
|
||||
className = ""
|
||||
}: GuidedReviewListProps) {
|
||||
if (items.length === 0) {
|
||||
return <div className={joinClasses("guided-review-list", "is-empty", className)}>{emptyText}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className={joinClasses("guided-review-list", className)}>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className={joinClasses("guided-review-row", item.tone ? `tone-${item.tone}` : undefined)}>
|
||||
<dt>{item.label}</dt>
|
||||
<dd>
|
||||
<strong>{item.value}</strong>
|
||||
{item.detail && <span>{item.detail}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type LoadingFrameProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -7,20 +8,22 @@ type LoadingFrameProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function LoadingFrame({ children, loading = false, label = "Loading data…", className = "" }: LoadingFrameProps) {
|
||||
export default function LoadingFrame({ children, loading = false, label = "i18n:govoplan-core.loading_data.089f19c5", className = "" }: LoadingFrameProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const translatedLabel = translateText(label);
|
||||
const classNames = ["loading-frame", loading ? "is-loading" : "", className].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className={classNames} aria-busy={loading || undefined}>
|
||||
{children}
|
||||
{loading && (
|
||||
<div className="loading-frame-overlay" role="status" aria-live="polite">
|
||||
{loading &&
|
||||
<div className="loading-frame-overlay" role="status" aria-live="polite">
|
||||
<div className="loading-frame-panel">
|
||||
<LoadingIndicator label={label} size="md" />
|
||||
<span>{label}</span>
|
||||
<LoadingIndicator label={translatedLabel} size="md" />
|
||||
<span>{translatedLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type LoadingIndicatorProps = {
|
||||
label?: string;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export default function LoadingIndicator({ label = "Loading", size = "sm" }: LoadingIndicatorProps) {
|
||||
export default function LoadingIndicator({ label = "i18n:govoplan-core.loading.8f26c652", size = "sm" }: LoadingIndicatorProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const translatedLabel = translateText(label);
|
||||
return (
|
||||
<span className={`loading-indicator loading-indicator-${size}`} role="status" aria-label={label} title={label}>
|
||||
<span className={`loading-indicator loading-indicator-${size}`} role="status" aria-label={translatedLabel} title={translatedLabel}>
|
||||
<span className="loading-envelope" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
</span>);
|
||||
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Archive, LockKeyhole, Paperclip } from "lucide-react";
|
||||
import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import SegmentedControl from "./SegmentedControl";
|
||||
|
||||
export type MessageDisplayField = {
|
||||
label: string;
|
||||
@@ -43,8 +45,9 @@ export default function MessageDisplayPanel({
|
||||
deriveTextFromHtml = true,
|
||||
headers = {},
|
||||
attachments = [],
|
||||
emptyText = "Select an item to inspect its content."
|
||||
emptyText = "i18n:govoplan-core.select_an_item_to_inspect_its_content.1f67f131"
|
||||
}: MessageDisplayPanelProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const visibleFields = fields.filter((field) => hasRenderableValue(field.value));
|
||||
const headerEntries = Object.entries(headers);
|
||||
const hasHtml = Boolean(bodyHtml?.trim());
|
||||
@@ -60,92 +63,99 @@ export default function MessageDisplayPanel({
|
||||
}, [defaultBodyMode, title, bodyText, bodyHtml, bodyPreview]);
|
||||
|
||||
if (!title && visibleFields.length === 0 && !hasText && !hasHtml && attachments.length === 0 && headerEntries.length === 0) {
|
||||
return <p className="muted">{emptyText}</p>;
|
||||
return <p className="muted">{translateText(emptyText)}</p>;
|
||||
}
|
||||
|
||||
const activeBodyMode = bodyMode === "html" && hasHtml ? "html" : "text";
|
||||
const showBodySwitch = hasHtml && hasText;
|
||||
const htmlBodyFallback = translateText("i18n:govoplan-core.p_no_html_body_content_p.c305bfc6");
|
||||
const textBodyFallback = translateText("i18n:govoplan-core.no_readable_body_content.37643a01");
|
||||
|
||||
return (
|
||||
<div className="message-display-panel">
|
||||
<div className="message-display-header">
|
||||
<h3>{title || "(no subject)"}</h3>
|
||||
{visibleFields.length > 0 && (
|
||||
<dl className="message-display-fields">
|
||||
{visibleFields.map((field) => (
|
||||
<div key={field.label}>
|
||||
<h3>{title || "i18n:govoplan-core.no_subject.49b20da0"}</h3>
|
||||
{visibleFields.length > 0 &&
|
||||
<dl className="message-display-fields">
|
||||
{visibleFields.map((field) =>
|
||||
<div key={field.label}>
|
||||
<dt>{field.label}</dt>
|
||||
<dd>{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
|
||||
<section className="message-display-body-section" aria-label="Message body">
|
||||
<section className="message-display-body-section" aria-label="i18n:govoplan-core.message_body.daee2627">
|
||||
<div className="message-display-section-heading">
|
||||
<h4>Message</h4>
|
||||
{showBodySwitch && (
|
||||
<div className="message-display-body-switch" role="tablist" aria-label="Message body format">
|
||||
<button type="button" className={activeBodyMode === "html" ? "active" : ""} onClick={() => setBodyMode("html")}>HTML</button>
|
||||
<button type="button" className={activeBodyMode === "text" ? "active" : ""} onClick={() => setBodyMode("text")}>Text</button>
|
||||
</div>
|
||||
)}
|
||||
<h4>i18n:govoplan-core.message.68f4145f</h4>
|
||||
{showBodySwitch &&
|
||||
<SegmentedControl
|
||||
ariaLabel="i18n:govoplan-core.message_body_format.5fec42d2"
|
||||
value={activeBodyMode}
|
||||
onChange={setBodyMode}
|
||||
options={[
|
||||
{ id: "html", label: "i18n:govoplan-core.html.9f738ce8" },
|
||||
{ id: "text", label: "i18n:govoplan-core.text.c3328c39" }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
{activeBodyMode === "html" ? (
|
||||
<iframe className="message-display-html-frame" title="Rendered HTML message body" sandbox="" srcDoc={bodyHtml || "<p>No HTML body content.</p>"} />
|
||||
) : (
|
||||
<pre className="message-display-body">{textBody || "No readable body content."}</pre>
|
||||
)}
|
||||
{activeBodyMode === "html" ?
|
||||
<iframe className="message-display-html-frame" title="i18n:govoplan-core.rendered_html_message_body.8638b634" sandbox="" srcDoc={bodyHtml || htmlBodyFallback} /> :
|
||||
|
||||
<pre className="message-display-body">{textBody || textBodyFallback}</pre>
|
||||
}
|
||||
</section>
|
||||
|
||||
{attachments.length ? (
|
||||
<div className="message-display-attachments">
|
||||
<h4>Attachments</h4>
|
||||
{attachments.length ?
|
||||
<div className="message-display-attachments">
|
||||
<h4>i18n:govoplan-core.attachments.6771ade6</h4>
|
||||
<div className="message-display-attachments-scroll">
|
||||
{groupedAttachments.direct.length > 0 && (
|
||||
<div className="message-display-attachment-list">
|
||||
{groupedAttachments.direct.length > 0 &&
|
||||
<div className="message-display-attachment-list">
|
||||
{groupedAttachments.direct.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
||||
</div>
|
||||
)}
|
||||
{groupedAttachments.archives.map((archive) => (
|
||||
<section className="message-display-attachment-archive" key={archive.key}>
|
||||
}
|
||||
{groupedAttachments.archives.map((archive) =>
|
||||
<section className="message-display-attachment-archive" key={archive.key}>
|
||||
<header>
|
||||
<div>
|
||||
<strong><Archive size={15} aria-hidden="true" /> {archive.label}</strong>
|
||||
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span>
|
||||
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} i18n:govoplan-core.inside_zip.68c3fd85</span>
|
||||
</div>
|
||||
{archive.protected && (
|
||||
<div className="message-display-attachment-protection">
|
||||
<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" /> i18n:govoplan-core.password_protected.09d9e174</small>
|
||||
{archive.protectionNote && <small className="message-display-attachment-protection-note">{formatProtectionNote(archive.protectionNote)}</small>}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</header>
|
||||
<div className="message-display-attachment-list">
|
||||
{archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div> :
|
||||
null}
|
||||
|
||||
{headerEntries.length > 0 && (
|
||||
<details className="message-display-headers">
|
||||
<summary>Headers</summary>
|
||||
{headerEntries.length > 0 &&
|
||||
<details className="message-display-headers">
|
||||
<summary>i18n:govoplan-core.headers.520de744</summary>
|
||||
<dl>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
|
||||
))}
|
||||
{headerEntries.map(([key, value]) =>
|
||||
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
|
||||
)}
|
||||
</dl>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function AttachmentRow({ attachment, index }: { attachment: MessageDisplayAttachment; index: number }) {
|
||||
function AttachmentRow({ attachment, index }: {attachment: MessageDisplayAttachment;index: number;}) {
|
||||
const contentType = formatContentType(attachment.contentType);
|
||||
const size = formatBytes(attachment.sizeBytes);
|
||||
const hasMeta = Boolean(contentType || size);
|
||||
@@ -153,17 +163,17 @@ function AttachmentRow({ attachment, index }: { attachment: MessageDisplayAttach
|
||||
<div className="message-display-attachment-row">
|
||||
<Paperclip size={14} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{attachment.filename || `Attachment ${index + 1}`}</strong>
|
||||
<strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong>
|
||||
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
|
||||
{hasMeta && (
|
||||
<small className="message-display-attachment-meta">
|
||||
{hasMeta &&
|
||||
<small className="message-display-attachment-meta">
|
||||
{contentType && <span title={contentType.full}>{contentType.label}</span>}
|
||||
{size && <span>{size}</span>}
|
||||
</small>
|
||||
)}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function groupAttachments(attachments: MessageDisplayAttachment[]) {
|
||||
@@ -198,45 +208,51 @@ function hasRenderableValue(value: ReactNode | null | undefined): boolean {
|
||||
}
|
||||
|
||||
function formatProtectionNote(value: string): string {
|
||||
return value
|
||||
.replace(/^Password-protected ZIP(?: using)?\s*/i, "")
|
||||
.replace(/^Password protected(?: using)?\s*/i, "")
|
||||
.replace(/^using\s+/i, "")
|
||||
.replace(/\.\s*Encryption:/i, " · Encryption:")
|
||||
.trim();
|
||||
const normalized = value.
|
||||
replace(/^Password-protected ZIP(?: using)?\s*/i, "").
|
||||
replace(/^Password protected(?: using)?\s*/i, "").
|
||||
replace(/^using\s+/i, "").
|
||||
trim();
|
||||
const encryptionMatch = normalized.match(/^(.*?)\.\s*Encryption:\s*(.*)$/i);
|
||||
if (!encryptionMatch) return normalized;
|
||||
const source = encryptionMatch[1].trim();
|
||||
const method = encryptionMatch[2].trim();
|
||||
return source ?
|
||||
i18nMessage("i18n:govoplan-core.value_encryption_value", { value0: source, value1: method }) :
|
||||
i18nMessage("i18n:govoplan-core.encryption_value", { value0: method });
|
||||
}
|
||||
|
||||
function formatContentType(value?: string | null): { label: string; full: string } | null {
|
||||
function formatContentType(value?: string | null): {label: string;full: string;} | null {
|
||||
const full = value?.trim();
|
||||
if (!full) return null;
|
||||
const normalized = full.toLowerCase();
|
||||
const known: Record<string, string> = {
|
||||
"application/pdf": "PDF",
|
||||
"application/zip": "ZIP archive",
|
||||
"application/x-zip-compressed": "ZIP archive",
|
||||
"application/octet-stream": "Binary file",
|
||||
"text/plain": "Plain text",
|
||||
"text/html": "HTML",
|
||||
"text/csv": "CSV",
|
||||
"application/json": "JSON",
|
||||
"image/jpeg": "JPEG image",
|
||||
"image/png": "PNG image",
|
||||
"image/gif": "GIF image",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX spreadsheet",
|
||||
"application/vnd.ms-excel": "Excel spreadsheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX document",
|
||||
"application/msword": "Word document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "PPTX presentation",
|
||||
"application/vnd.ms-powerpoint": "PowerPoint presentation"
|
||||
"application/pdf": "i18n:govoplan-core.pdf.d613d88c",
|
||||
"application/zip": "i18n:govoplan-core.zip_archive.5a2430dd",
|
||||
"application/x-zip-compressed": "i18n:govoplan-core.zip_archive.5a2430dd",
|
||||
"application/octet-stream": "i18n:govoplan-core.binary_file.a4de1904",
|
||||
"text/plain": "i18n:govoplan-core.plain_text.9580fcbc",
|
||||
"text/html": "i18n:govoplan-core.html.9f738ce8",
|
||||
"text/csv": "i18n:govoplan-core.csv.32811883",
|
||||
"application/json": "i18n:govoplan-core.json.031a4e76",
|
||||
"image/jpeg": "i18n:govoplan-core.jpeg_image.6876b7ff",
|
||||
"image/png": "i18n:govoplan-core.png_image.6715d4b8",
|
||||
"image/gif": "i18n:govoplan-core.gif_image.d092a779",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9",
|
||||
"application/vnd.ms-excel": "i18n:govoplan-core.excel_spreadsheet.7107fea2",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "i18n:govoplan-core.docx_document.f0b09822",
|
||||
"application/msword": "i18n:govoplan-core.word_document.f593629d",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "i18n:govoplan-core.pptx_presentation.855d193b",
|
||||
"application/vnd.ms-powerpoint": "i18n:govoplan-core.powerpoint_presentation.b32b7115"
|
||||
};
|
||||
if (known[normalized]) return { label: known[normalized], full };
|
||||
const subtype = normalized.split("/")[1]?.split(";")[0] || normalized;
|
||||
const cleaned = subtype
|
||||
.replace(/^vnd\./, "")
|
||||
.replace(/^x-/, "")
|
||||
.replace(/openxmlformats-officedocument\./g, "")
|
||||
.replace(/[.+_-]+/g, " ")
|
||||
.trim();
|
||||
const cleaned = subtype.
|
||||
replace(/^vnd\./, "").
|
||||
replace(/^x-/, "").
|
||||
replace(/openxmlformats-officedocument\./g, "").
|
||||
replace(/[.+_-]+/g, " ").
|
||||
trim();
|
||||
const label = titleCase(cleaned || normalized);
|
||||
return { label: label.length > 34 ? `${label.slice(0, 31)}...` : label, full };
|
||||
}
|
||||
@@ -247,9 +263,9 @@ function titleCase(value: string): string {
|
||||
|
||||
function formatBytes(value?: number | null): string {
|
||||
if (!value) return "";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (value < 1024) return i18nMessage("i18n:govoplan-core.bytes_b", { value0: value });
|
||||
if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-core.bytes_kb", { value0: (value / 1024).toFixed(1) });
|
||||
return i18nMessage("i18n:govoplan-core.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
|
||||
}
|
||||
|
||||
function stripHtml(value: string): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type PageTitleProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -6,10 +7,12 @@ type PageTitleProps = {
|
||||
};
|
||||
|
||||
export default function PageTitle({ children, loading = false }: PageTitleProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedChildren = typeof children === "string" ? translateText(children) : children;
|
||||
return (
|
||||
<h1 className="page-title-with-loader">
|
||||
<span>{children}</span>
|
||||
{loading && <LoadingIndicator label="Loading page data" />}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
<span>{renderedChildren}</span>
|
||||
{loading && <LoadingIndicator label="i18n:govoplan-core.loading_page_data.85fe9edf" />}
|
||||
</h1>);
|
||||
|
||||
}
|
||||
@@ -16,8 +16,8 @@ export default function PasswordField({
|
||||
onValueChange,
|
||||
saved = false,
|
||||
savedPlaceholder = "••••••••",
|
||||
revealLabel = "Show password",
|
||||
hideLabel = "Hide password",
|
||||
revealLabel = "i18n:govoplan-core.show_password.044b852f",
|
||||
hideLabel = "i18n:govoplan-core.hide_password.e40123b4",
|
||||
placeholder,
|
||||
disabled = false,
|
||||
className = "",
|
||||
@@ -46,19 +46,19 @@ export default function PasswordField({
|
||||
onChange={(event) => {
|
||||
onValueChange(event.target.value);
|
||||
if (!event.target.value) setVisible(false);
|
||||
}}
|
||||
/>
|
||||
{canReveal && (
|
||||
<button
|
||||
type="button"
|
||||
className="password-field-toggle"
|
||||
aria-label={visible ? hideLabel : revealLabel}
|
||||
title={visible ? hideLabel : revealLabel}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
>
|
||||
}} />
|
||||
|
||||
{canReveal &&
|
||||
<button
|
||||
type="button"
|
||||
className="password-field-toggle"
|
||||
aria-label={visible ? hideLabel : revealLabel}
|
||||
title={visible ? hideLabel : revealLabel}
|
||||
onClick={() => setVisible((current) => !current)}>
|
||||
|
||||
{visible ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export type PolicySourcePathItem = string | {
|
||||
scope_type?: string;
|
||||
scope_id?: string | null;
|
||||
path?: string;
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
appliedFields?: string[];
|
||||
@@ -15,10 +16,12 @@ export type PolicySourcePathProps = {
|
||||
|
||||
type NormalizedPolicySourcePathItem = {
|
||||
label: string;
|
||||
path?: string;
|
||||
appliedFields: string[];
|
||||
policy?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export default function PolicySourcePath({ items, label = "Source path", className = "" }: PolicySourcePathProps) {
|
||||
export default function PolicySourcePath({ items, label = "i18n:govoplan-core.source_path.5956aebe", className = "" }: PolicySourcePathProps) {
|
||||
const cleanItems = items.map(normalizeSourceItem).filter((item): item is NormalizedPolicySourcePathItem => Boolean(item?.label));
|
||||
if (cleanItems.length === 0) return null;
|
||||
|
||||
@@ -26,15 +29,15 @@ export default function PolicySourcePath({ items, label = "Source path", classNa
|
||||
<div className={`policy-source-path ${className}`.trim()} aria-label={label}>
|
||||
<span className="policy-source-path-label">{label}</span>
|
||||
<ol>
|
||||
{cleanItems.map((item, index) => (
|
||||
<li key={`${item.label}-${index}`}>
|
||||
<span>{item.label}</span>
|
||||
{item.appliedFields.length > 0 && <small>{sourceFieldSummary(item.appliedFields)}</small>}
|
||||
{cleanItems.map((item, index) =>
|
||||
<li key={`${item.label}-${index}`}>
|
||||
<span title={item.path}>{item.label}</span>
|
||||
{item.appliedFields.length > 0 && <small title={sourceFieldSummary(item.appliedFields, item.policy)}>{sourceFieldSummary(item.appliedFields, item.policy)}</small>}
|
||||
</li>
|
||||
))}
|
||||
)}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function normalizeSourceItem(item: PolicySourcePathItem): NormalizedPolicySourcePathItem | null {
|
||||
@@ -45,23 +48,46 @@ function normalizeSourceItem(item: PolicySourcePathItem): NormalizedPolicySource
|
||||
const label = item.label?.trim();
|
||||
if (!label) return null;
|
||||
const appliedFields = [...(item.applied_fields ?? item.appliedFields ?? [])].map((field) => field.trim()).filter(Boolean);
|
||||
return { label, appliedFields };
|
||||
return { label, path: item.path, appliedFields, policy: item.policy };
|
||||
}
|
||||
|
||||
function sourceFieldSummary(fields: string[]): string {
|
||||
function sourceFieldSummary(fields: string[], policy?: Record<string, unknown> | null): string {
|
||||
const unique = Array.from(new Set(fields));
|
||||
if (unique.length === 0) return "";
|
||||
if (unique.length === 1 && unique[0] === "defaults") return "defaults";
|
||||
if (unique.length <= 3) return unique.map(sourceFieldLabel).join(", ");
|
||||
if (unique.length <= 3) return unique.map((field) => sourceFieldDetail(field, policy)).join(", ");
|
||||
return `${unique.slice(0, 2).map(sourceFieldLabel).join(", ")} +${unique.length - 2}`;
|
||||
}
|
||||
|
||||
function sourceFieldDetail(field: string, policy?: Record<string, unknown> | null): string {
|
||||
const value = readPolicyField(policy, field);
|
||||
const label = sourceFieldLabel(field);
|
||||
if (value === undefined) return label;
|
||||
if (typeof value === "boolean") return `${label}: ${value ? "allow" : "deny"}`;
|
||||
if (typeof value === "number" || typeof value === "string") return `${label}: ${value}`;
|
||||
if (value === null) return `${label}: none`;
|
||||
if (Array.isArray(value)) return `${label}: ${value.length}`;
|
||||
if (typeof value === "object") {
|
||||
const locked = Object.values(value as Record<string, unknown>).filter((item) => item === false).length;
|
||||
return locked > 0 ? `${label}: ${locked} locked` : label;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function readPolicyField(policy: Record<string, unknown> | null | undefined, field: string): unknown {
|
||||
if (!policy) return undefined;
|
||||
return field.split(".").reduce<unknown>((current, segment) => {
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
|
||||
return (current as Record<string, unknown>)[segment];
|
||||
}, policy);
|
||||
}
|
||||
|
||||
function sourceFieldLabel(field: string): string {
|
||||
if (field === "defaults") return "defaults";
|
||||
const clean = field
|
||||
.replace(/^whitelist[.]/, "allow ")
|
||||
.replace(/^blacklist[.]/, "block ")
|
||||
.replace(/[_.]/g, " ")
|
||||
.trim();
|
||||
const clean = field.
|
||||
replace(/^whitelist[.]/, "allow ").
|
||||
replace(/^blacklist[.]/, "block ").
|
||||
replace(/[_.]/g, " ").
|
||||
trim();
|
||||
return clean.charAt(0).toUpperCase() + clean.slice(1);
|
||||
}
|
||||
|
||||
131
webui/src/components/PolicyTable.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { ReactNode } from "react";
|
||||
import FieldLabel from "./help/FieldLabel";
|
||||
|
||||
type PolicySectionProps = {
|
||||
title?: ReactNode;
|
||||
summary?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
headingClassName?: string;
|
||||
};
|
||||
|
||||
type PolicyTableProps = {
|
||||
children: ReactNode;
|
||||
fieldLabel: ReactNode;
|
||||
settingLabel: ReactNode;
|
||||
effectiveLabel?: ReactNode;
|
||||
lowerLevelLabel?: ReactNode;
|
||||
showEffectiveColumn?: boolean;
|
||||
showAllowColumn?: boolean;
|
||||
className?: string;
|
||||
rowClassName?: string;
|
||||
headerClassName?: string;
|
||||
};
|
||||
|
||||
type PolicyRowProps = {
|
||||
label: ReactNode;
|
||||
help?: ReactNode;
|
||||
note?: ReactNode;
|
||||
control: ReactNode;
|
||||
effective?: ReactNode;
|
||||
effectiveHelp?: ReactNode;
|
||||
allowControl?: ReactNode;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
controlClassName?: string;
|
||||
effectiveClassName?: string;
|
||||
effectiveCellClassName?: string;
|
||||
};
|
||||
|
||||
export function PolicySection({
|
||||
title,
|
||||
summary,
|
||||
actions,
|
||||
children,
|
||||
className = "",
|
||||
headingClassName = "subsection-heading split"
|
||||
}: PolicySectionProps) {
|
||||
return (
|
||||
<section className={["policy-section", className].filter(Boolean).join(" ")}>
|
||||
{(title || summary || actions) &&
|
||||
<div className={headingClassName}>
|
||||
{title && <h3>{title}</h3>}
|
||||
{actions ?? summary}
|
||||
</div>
|
||||
}
|
||||
{children}
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
export function PolicyTable({
|
||||
children,
|
||||
fieldLabel,
|
||||
settingLabel,
|
||||
effectiveLabel,
|
||||
lowerLevelLabel,
|
||||
showEffectiveColumn = false,
|
||||
showAllowColumn = false,
|
||||
className = "",
|
||||
rowClassName = "",
|
||||
headerClassName = ""
|
||||
}: PolicyTableProps) {
|
||||
return (
|
||||
<div className={[
|
||||
"policy-table",
|
||||
showEffectiveColumn ? "with-effective-column" : "",
|
||||
showAllowColumn ? "with-allow-column" : "",
|
||||
className].
|
||||
filter(Boolean).join(" ")}>
|
||||
<div className={[rowClassName, "policy-row", "policy-row-header", headerClassName].filter(Boolean).join(" ")}>
|
||||
<span>{fieldLabel}</span>
|
||||
<span>{settingLabel}</span>
|
||||
{showEffectiveColumn && <span>{effectiveLabel}</span>}
|
||||
{showAllowColumn && <span>{lowerLevelLabel}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function PolicyRow({
|
||||
label,
|
||||
help,
|
||||
note,
|
||||
control,
|
||||
effective,
|
||||
effectiveHelp,
|
||||
allowControl,
|
||||
className = "",
|
||||
labelClassName = "",
|
||||
controlClassName = "",
|
||||
effectiveClassName = "",
|
||||
effectiveCellClassName = ""
|
||||
}: PolicyRowProps) {
|
||||
return (
|
||||
<div className={["policy-row", className].filter(Boolean).join(" ")}>
|
||||
<div className={["policy-field-label", labelClassName].filter(Boolean).join(" ")}>
|
||||
{help ?
|
||||
<FieldLabel className="policy-field-title" help={help}>{label}</FieldLabel> :
|
||||
<strong>{label}</strong>
|
||||
}
|
||||
{note && (typeof note === "string" ? <small>{note}</small> : note)}
|
||||
</div>
|
||||
<div className={["policy-control", controlClassName].filter(Boolean).join(" ")}>{control}</div>
|
||||
{effective !== undefined &&
|
||||
<div className={["policy-effective-cell", effectiveCellClassName].filter(Boolean).join(" ")}>
|
||||
{effectiveHelp ?
|
||||
<FieldLabel className={["policy-effective-value", effectiveClassName].filter(Boolean).join(" ")} help={effectiveHelp}>
|
||||
{effective}
|
||||
</FieldLabel> :
|
||||
<span className={["policy-effective-value", effectiveClassName].filter(Boolean).join(" ")}>
|
||||
{effective}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{allowControl}
|
||||
</div>);
|
||||
|
||||
}
|
||||
76
webui/src/components/SegmentedControl.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
export type SegmentedControlSize = "content" | "equal";
|
||||
export type SegmentedControlWidth = "inline" | "fill";
|
||||
|
||||
export type SegmentedControlOption<TValue extends string = string> = {
|
||||
id: TValue;
|
||||
label: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export type SegmentedControlProps<TValue extends string = string> = {
|
||||
options: Array<SegmentedControlOption<TValue>>;
|
||||
value: TValue;
|
||||
onChange: (value: TValue) => void;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
className?: string;
|
||||
optionClassName?: string;
|
||||
disabled?: boolean;
|
||||
role?: "group" | "tablist";
|
||||
size?: SegmentedControlSize;
|
||||
width?: SegmentedControlWidth;
|
||||
};
|
||||
|
||||
export default function SegmentedControl<TValue extends string = string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
className = "",
|
||||
optionClassName = "",
|
||||
disabled = false,
|
||||
role = "tablist",
|
||||
size = "content",
|
||||
width = "inline"
|
||||
}: SegmentedControlProps<TValue>) {
|
||||
const rootClassName = [
|
||||
"segmented-control",
|
||||
`segmented-control-size-${size}`,
|
||||
`segmented-control-width-${width}`,
|
||||
className
|
||||
].filter(Boolean).join(" ");
|
||||
const optionRole = role === "tablist" ? "tab" : undefined;
|
||||
|
||||
return (
|
||||
<div className={rootClassName} role={role} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy}>
|
||||
{options.map((option) => {
|
||||
const selected = option.id === value;
|
||||
const buttonClassName = [
|
||||
"segmented-control-option",
|
||||
selected ? "is-active" : "",
|
||||
optionClassName
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role={optionRole}
|
||||
aria-selected={role === "tablist" ? selected : undefined}
|
||||
aria-pressed={role === "group" ? selected : undefined}
|
||||
aria-label={option.ariaLabel}
|
||||
title={option.title}
|
||||
className={buttonClassName}
|
||||
disabled={disabled || option.disabled}
|
||||
onClick={() => onChange(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export default function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{label ?? status}</span>;
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{translateText(label ?? status)}</span>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import FieldLabel from "./help/FieldLabel";
|
||||
import { helpForFieldLabel } from "../utils/fieldHelp";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type ToggleSwitchProps = {
|
||||
label: ReactNode;
|
||||
@@ -11,6 +12,8 @@ type ToggleSwitchProps = {
|
||||
};
|
||||
|
||||
export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||
return (
|
||||
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
|
||||
<input
|
||||
@@ -22,7 +25,7 @@ export default function ToggleSwitch({ label, checked, onChange, disabled = fals
|
||||
/>
|
||||
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
|
||||
<span className="toggle-switch-copy">
|
||||
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{label}</FieldLabel>
|
||||
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, type NavigateFunction, type NavigateOptions, type To } from "react-router-dom";
|
||||
import Button from "./Button";
|
||||
import Dialog from "./Dialog";
|
||||
import DismissibleAlert from "./DismissibleAlert";
|
||||
@@ -13,6 +13,15 @@ export type UnsavedChangesRegistration = {
|
||||
onDiscard?: () => void;
|
||||
};
|
||||
|
||||
export type UnsavedDraftGuardOptions = {
|
||||
dirty: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
onSave: () => boolean | Promise<boolean>;
|
||||
onDiscard?: () => void;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: boolean;
|
||||
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
||||
@@ -21,13 +30,15 @@ type UnsavedChangesContextValue = {
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
|
||||
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||
const navigate = useNavigate();
|
||||
const [registration, setRegistration] = useState<UnsavedChangesRegistration | null>(null);
|
||||
const [registrations, setRegistrations] = useState<Array<{id: number;registration: UnsavedChangesRegistration;}>>([]);
|
||||
const [pendingAction, setPendingAction] = useState<UnsavedNavigationAction | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const registrationRef = useRef<UnsavedChangesRegistration | null>(null);
|
||||
const nextRegistrationId = useRef(1);
|
||||
const registration = registrations.length ? registrations[registrations.length - 1].registration : null;
|
||||
|
||||
useEffect(() => {
|
||||
registrationRef.current = registration;
|
||||
@@ -36,9 +47,12 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
const hasUnsavedChanges = Boolean(registration);
|
||||
|
||||
const registerUnsavedChanges = useCallback((next: UnsavedChangesRegistration | null) => {
|
||||
setRegistration(next);
|
||||
if (!next) return () => undefined;
|
||||
const id = nextRegistrationId.current;
|
||||
nextRegistrationId.current += 1;
|
||||
setRegistrations((current) => [...current, { id, registration: next }]);
|
||||
return () => {
|
||||
setRegistration((current) => current === next ? null : current);
|
||||
setRegistrations((current) => current.filter((item) => item.id !== id));
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -60,9 +74,12 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!registrationRef.current) return;
|
||||
const active = registrationRef.current;
|
||||
if (!active) return;
|
||||
const message = active.message || "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b";
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
event.returnValue = message;
|
||||
return message;
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
@@ -111,7 +128,7 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const ok = await active.onSave();
|
||||
if (!ok) {
|
||||
setSaveError("The changes could not be saved. Please review the page message and try again.");
|
||||
setSaveError("i18n:govoplan-core.the_changes_could_not_be_saved_please_review_the.4615a3c6");
|
||||
return;
|
||||
}
|
||||
proceed(action);
|
||||
@@ -139,30 +156,30 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
{children}
|
||||
{pendingAction && registration && (
|
||||
<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>
|
||||
{pendingAction && registration &&
|
||||
<Dialog
|
||||
open
|
||||
role="alertdialog"
|
||||
title={registration.title ?? "i18n:govoplan-core.unsaved_changes.29267269"}
|
||||
className="unsaved-changes-dialog"
|
||||
footerClassName="unsaved-changes-actions"
|
||||
closeOnBackdrop={!saving}
|
||||
closeDisabled={saving}
|
||||
onClose={() => setPendingAction(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setPendingAction(null)} disabled={saving}>i18n:govoplan-core.cancel.77dfd213</Button>
|
||||
<Button onClick={handleDiscardAndLeave} disabled={saving}>i18n:govoplan-core.discard.36fff63c</Button>
|
||||
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "i18n:govoplan-core.saving.ae7e8875" : "i18n:govoplan-core.save_and_leave.0507824a"}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
|
||||
}>
|
||||
|
||||
<p>{registration.message ?? "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b"}</p>
|
||||
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
|
||||
</Dialog>
|
||||
)}
|
||||
</UnsavedChangesContext.Provider>
|
||||
);
|
||||
}
|
||||
</UnsavedChangesContext.Provider>);
|
||||
|
||||
}
|
||||
|
||||
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
||||
@@ -182,3 +199,44 @@ export function useRegisterUnsavedChanges(registration: UnsavedChangesRegistrati
|
||||
return registerUnsavedChanges(registration);
|
||||
}, [registerUnsavedChanges, registration]);
|
||||
}
|
||||
|
||||
export function useUnsavedDraftGuard({
|
||||
dirty,
|
||||
title = "i18n:govoplan-core.unsaved_changes.29267269",
|
||||
message = "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b",
|
||||
onSave,
|
||||
onDiscard,
|
||||
enabled = true
|
||||
}: UnsavedDraftGuardOptions) {
|
||||
const saveRef = useRef(onSave);
|
||||
const discardRef = useRef(onDiscard);
|
||||
|
||||
useEffect(() => {
|
||||
saveRef.current = onSave;
|
||||
discardRef.current = onDiscard;
|
||||
}, [onDiscard, onSave]);
|
||||
|
||||
const registration = useMemo<UnsavedChangesRegistration | null>(() => enabled && dirty ? {
|
||||
title,
|
||||
message,
|
||||
onSave: () => saveRef.current(),
|
||||
onDiscard: () => discardRef.current?.()
|
||||
} : null, [dirty, enabled, message, title]);
|
||||
|
||||
useRegisterUnsavedChanges(registration);
|
||||
}
|
||||
|
||||
export function useGuardedNavigate(): NavigateFunction {
|
||||
const navigate = useNavigate();
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
|
||||
return useCallback(((to: To | number, options?: NavigateOptions) => {
|
||||
requestNavigation(() => {
|
||||
if (typeof to === "number") {
|
||||
navigate(to);
|
||||
} else {
|
||||
navigate(to, options);
|
||||
}
|
||||
});
|
||||
}) as NavigateFunction, [navigate, requestNavigation]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "../Button";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
@@ -10,13 +11,15 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function AdminIconButton({ label, icon, onClick, disabled = false, variant = "secondary" }: Props) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const translatedLabel = translateText(label);
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
className="admin-icon-button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
aria-label={translatedLabel}
|
||||
title={translatedLabel}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
import LoadingFrame from "../LoadingFrame";
|
||||
import PageTitle from "../PageTitle";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@@ -19,27 +20,28 @@ export default function AdminPageLayout({
|
||||
title,
|
||||
description,
|
||||
loading = false,
|
||||
loadingLabel = "Loading administration data...",
|
||||
loadingLabel = "i18n:govoplan-core.loading_administration_data.643bd894",
|
||||
error = "",
|
||||
success = "",
|
||||
actions,
|
||||
children,
|
||||
className = ""
|
||||
}: Props) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return (
|
||||
<div className={`admin-section-page ${className}`.trim()}>
|
||||
<div className="page-heading split workspace-heading admin-page-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{title}</PageTitle>
|
||||
<p>{description}</p>
|
||||
<p>{translateText(description)}</p>
|
||||
</div>
|
||||
{actions && <div className="button-row compact-actions admin-page-actions">{actions}</div>}
|
||||
</div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
<LoadingFrame loading={loading} label={loadingLabel}>
|
||||
<LoadingFrame loading={loading} label={translateText(loadingLabel)}>
|
||||
{children}
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type Option = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -9,36 +11,37 @@ export default function AdminSelectionList({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
emptyText = "No options available."
|
||||
}: {
|
||||
options: Option[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
if (!options.length) return <p className="muted small-note">{emptyText}</p>;
|
||||
emptyText = "i18n:govoplan-core.no_options_available.a88ab045"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {options: Option[];selected: string[];onChange: (next: string[]) => void;emptyText?: string;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
if (!options.length) return <p className="muted small-note">{translateText(emptyText)}</p>;
|
||||
const selectedSet = new Set(selected);
|
||||
return (
|
||||
<div className="admin-selection-list">
|
||||
{options.map((option) => (
|
||||
<label key={option.id} className={`admin-selection-item ${option.disabled ? "disabled" : ""}`}>
|
||||
{options.map((option) =>
|
||||
<label key={option.id} className={`admin-selection-item ${option.disabled ? "disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(option.id)}
|
||||
disabled={option.disabled}
|
||||
onChange={(event) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (event.target.checked) next.add(option.id);
|
||||
else next.delete(option.id);
|
||||
onChange(Array.from(next));
|
||||
}}
|
||||
/>
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(option.id)}
|
||||
disabled={option.disabled}
|
||||
onChange={(event) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (event.target.checked) next.add(option.id);else
|
||||
next.delete(option.id);
|
||||
onChange(Array.from(next));
|
||||
}} />
|
||||
|
||||
<span>
|
||||
<strong>{option.label}</strong>
|
||||
{option.description && <small>{option.description}</small>}
|
||||
<strong>{translateText(option.label)}</strong>
|
||||
{option.description && <small>{translateText(option.description)}</small>}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,27 +1,41 @@
|
||||
import { formatDateTime as formatPlatformDateTime } from "../../utils/datetime";
|
||||
|
||||
export function adminErrorMessage(error: unknown): string {
|
||||
if (!(error instanceof Error)) return "The administrative operation failed.";
|
||||
if (!(error instanceof Error)) return "i18n:govoplan-core.the_administrative_operation_failed.0791d379";
|
||||
const message = error.message;
|
||||
const jsonStart = message.indexOf("{");
|
||||
if (jsonStart >= 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(message.slice(jsonStart)) as { detail?: unknown };
|
||||
const parsed = JSON.parse(message.slice(jsonStart)) as {detail?: unknown;};
|
||||
if (typeof parsed.detail === "string") return parsed.detail;
|
||||
if (Array.isArray(parsed.detail)) {
|
||||
return parsed.detail.map((item) => typeof item === "object" && item && "msg" in item ? String((item as { msg: unknown }).msg) : String(item)).join("; ");
|
||||
return parsed.detail.map((item) => typeof item === "object" && item && "msg" in item ? String((item as {msg: unknown;}).msg) : String(item)).join("; ");
|
||||
}
|
||||
if (typeof parsed.detail === "object" && parsed.detail) {
|
||||
const detail = parsed.detail as {message?: unknown;plan?: {blockers?: unknown;warnings?: unknown;};};
|
||||
const blockers = detail.plan?.blockers;
|
||||
const warnings = detail.plan?.warnings;
|
||||
const parts = [];
|
||||
if (typeof detail.message === "string") parts.push(detail.message);
|
||||
if (Array.isArray(blockers) && blockers.length > 0) {
|
||||
parts.push(`Blockers: ${blockers.map(String).join(", ")}`);
|
||||
}
|
||||
if (Array.isArray(warnings) && warnings.length > 0) {
|
||||
parts.push(`Warnings: ${warnings.map(String).join(", ")}`);
|
||||
}
|
||||
if (parts.length > 0) return parts.join(" ");
|
||||
}
|
||||
} catch {
|
||||
|
||||
|
||||
// Fall through to the original message.
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}}return message;
|
||||
}
|
||||
|
||||
export function formatAdminDateTime(value?: string | null): string {
|
||||
return formatPlatformDateTime(value, { fallback: "Never" });
|
||||
return formatPlatformDateTime(value, { fallback: "i18n:govoplan-core.never.80c3052d" });
|
||||
}
|
||||
|
||||
export function joinLabels(values: Array<{ name: string }>): string {
|
||||
export function joinLabels(values: Array<{name: string;}>): string {
|
||||
return values.length ? values.map((value) => value.name).join(", ") : "—";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { i18nMessage } from "../../i18n/LanguageContext";import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Button from "../Button";
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
isValidEmailAddress,
|
||||
normalizeEmailAddress,
|
||||
parseMailboxAddressText,
|
||||
type MailboxAddress
|
||||
} from "../../utils/emailAddresses";
|
||||
type MailboxAddress } from
|
||||
"../../utils/emailAddresses";
|
||||
|
||||
type EmailAddressInputProps = {
|
||||
value: MailboxAddress[];
|
||||
@@ -35,10 +35,10 @@ export default function EmailAddressInput({
|
||||
allowMultiple = true,
|
||||
clearOnAdd = false,
|
||||
disabled = false,
|
||||
addLabel = "Add",
|
||||
namePlaceholder = "Name",
|
||||
addLabel = "i18n:govoplan-core.add.61cc55aa",
|
||||
namePlaceholder = "i18n:govoplan-core.name.709a2322",
|
||||
emailPlaceholder = "email@example.org",
|
||||
emptyText = "No address added yet.",
|
||||
emptyText = "i18n:govoplan-core.no_address_added_yet.809c4247",
|
||||
compact = false,
|
||||
showAddButton
|
||||
}: EmailAddressInputProps) {
|
||||
@@ -57,9 +57,9 @@ export default function EmailAddressInput({
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
const query = entryText.trim().toLowerCase();
|
||||
if (!query) return normalizedSuggestions.slice(0, 6);
|
||||
return normalizedSuggestions
|
||||
.filter((item) => `${item.name ?? ""} ${item.email}`.toLowerCase().includes(query))
|
||||
.slice(0, 6);
|
||||
return normalizedSuggestions.
|
||||
filter((item) => `${item.name ?? ""} ${item.email}`.toLowerCase().includes(query)).
|
||||
slice(0, 6);
|
||||
}, [entryText, normalizedSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,17 +103,17 @@ export default function EmailAddressInput({
|
||||
function commitAddress(candidate: MailboxAddress | null, sourceText = "") {
|
||||
if (disabled) return false;
|
||||
if (!candidate?.email) {
|
||||
setError(sourceText ? "Use a valid address such as Name <email@example.org>." : "Enter an email address first.");
|
||||
setError(sourceText ? "i18n:govoplan-core.use_a_valid_address_such_as_name_email_example_o.39cdbc3c" : "i18n:govoplan-core.enter_an_email_address_first.6d55dfd1");
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = normalizeEmailAddress(candidate);
|
||||
if (!isValidEmailAddress(normalized.email)) {
|
||||
setError("Enter a valid email address.");
|
||||
setError("i18n:govoplan-core.enter_a_valid_email_address.2e09edea");
|
||||
return false;
|
||||
}
|
||||
if (allowMultiple && normalizedValue.some((address) => address.email === normalized.email)) {
|
||||
setError("This address is already listed.");
|
||||
setError("i18n:govoplan-core.this_address_is_already_listed.0f6d0ff2");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -166,19 +166,19 @@ export default function EmailAddressInput({
|
||||
aria-labelledby={`${inputId}-dialog-title`}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
<h4 id={`${inputId}-dialog-title`}>Add address</h4>
|
||||
}}>
|
||||
|
||||
<h4 id={`${inputId}-dialog-title`}>i18n:govoplan-core.add_address.a71075c4</h4>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<span>i18n:govoplan-core.name.709a2322</span>
|
||||
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus />
|
||||
</label>
|
||||
<label>
|
||||
<span>Email address</span>
|
||||
<span>i18n:govoplan-core.email_address.c94d3175</span>
|
||||
<input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" />
|
||||
</label>
|
||||
<div className="button-row compact-actions">
|
||||
<Button type="button" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button type="button" onClick={() => setDialogOpen(false)}>i18n:govoplan-core.cancel.77dfd213</Button>
|
||||
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -193,54 +193,54 @@ export default function EmailAddressInput({
|
||||
{normalizedValue.map((address) => {
|
||||
const valid = isValidEmailAddress(address.email);
|
||||
return (
|
||||
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "Invalid email address"}>
|
||||
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "i18n:govoplan-core.invalid_email_address.9e4ee6d7"}>
|
||||
<span className="email-chip-main">{addressDisplayName(address)}</span>
|
||||
{address.name && <span className="email-chip-address">{address.email}</span>}
|
||||
{!disabled && (
|
||||
<button type="button" className="email-chip-remove" aria-label={`Remove ${address.email}`} onClick={() => removeAddress(address.email)}>
|
||||
{!disabled &&
|
||||
<button type="button" className="email-chip-remove" aria-label={i18nMessage("i18n:govoplan-core.remove_value.a2d40d33", { value0: address.email })} onClick={() => removeAddress(address.email)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
</span>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<>
|
||||
{!disabled &&
|
||||
<>
|
||||
<textarea
|
||||
id={inputId}
|
||||
className="email-address-textarea"
|
||||
rows={1}
|
||||
value={entryText}
|
||||
onChange={(event) => {
|
||||
setEntryText(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={handleTextKeyDown}
|
||||
placeholder={`${namePlaceholder} <${emailPlaceholder}>`}
|
||||
aria-label="Type a name and email address, then press Enter"
|
||||
/>
|
||||
{canUseAddButton && (
|
||||
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="Open address form" title="Add address with form" onClick={() => setDialogOpen((open) => !open)}>
|
||||
id={inputId}
|
||||
className="email-address-textarea"
|
||||
rows={1}
|
||||
value={entryText}
|
||||
onChange={(event) => {
|
||||
setEntryText(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={handleTextKeyDown}
|
||||
placeholder={i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: namePlaceholder, value1: emailPlaceholder })}
|
||||
aria-label="i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0" />
|
||||
|
||||
{canUseAddButton &&
|
||||
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="i18n:govoplan-core.open_address_form.f8ee560f" title="i18n:govoplan-core.add_address_with_form.13b3b3e7" onClick={() => setDialogOpen((open) => !open)}>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
}
|
||||
</>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
|
||||
{!disabled && filteredSuggestions.length > 0 && entryText.trim() && (
|
||||
<div className="email-address-suggestions" role="listbox" aria-label="Address suggestions">
|
||||
{filteredSuggestions.map((item) => (
|
||||
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
|
||||
{!disabled && filteredSuggestions.length > 0 && entryText.trim() &&
|
||||
<div className="email-address-suggestions" role="listbox" aria-label="i18n:govoplan-core.address_suggestions.45ba4a20">
|
||||
{filteredSuggestions.map((item) =>
|
||||
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
|
||||
<span>{addressDisplayName(item)}</span>
|
||||
{item.name && <small>{item.email}</small>}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
{error && <p className="form-help danger-text">{error}</p>}
|
||||
{addressDialog}
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type InlineHelpProps = {
|
||||
children: ReactNode;
|
||||
@@ -42,6 +43,7 @@ function clamp(value: number, min: number, max: number) {
|
||||
}
|
||||
|
||||
export default function InlineHelp({ children, className = "" }: InlineHelpProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const tooltipId = useId();
|
||||
const triggerRef = useRef<HTMLSpanElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -82,9 +84,9 @@ export default function InlineHelp({ children, className = "" }: InlineHelpProps
|
||||
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
|
||||
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
|
||||
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
|
||||
const top = hasRoomAbove
|
||||
? topCandidate
|
||||
: clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
|
||||
const top = hasRoomAbove ?
|
||||
topCandidate :
|
||||
clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
|
||||
|
||||
setPosition({
|
||||
top,
|
||||
@@ -135,52 +137,52 @@ export default function InlineHelp({ children, className = "" }: InlineHelpProps
|
||||
opacity: position ? 1 : 0
|
||||
};
|
||||
|
||||
const arrowStyle: CSSProperties = position?.placement === "bottom"
|
||||
? {
|
||||
position: "absolute",
|
||||
left: position.arrowLeft,
|
||||
top: 0,
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderLeft: "1px solid var(--line-dark)",
|
||||
borderTop: "1px solid var(--line-dark)",
|
||||
background: "var(--surface)",
|
||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||
}
|
||||
: {
|
||||
position: "absolute",
|
||||
left: position?.arrowLeft ?? 16,
|
||||
top: "100%",
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRight: "1px solid var(--line-dark)",
|
||||
borderBottom: "1px solid var(--line-dark)",
|
||||
background: "var(--surface)",
|
||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||
};
|
||||
const arrowStyle: CSSProperties = position?.placement === "bottom" ?
|
||||
{
|
||||
position: "absolute",
|
||||
left: position.arrowLeft,
|
||||
top: 0,
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderLeft: "1px solid var(--line-dark)",
|
||||
borderTop: "1px solid var(--line-dark)",
|
||||
background: "var(--surface)",
|
||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||
} :
|
||||
{
|
||||
position: "absolute",
|
||||
left: position?.arrowLeft ?? 16,
|
||||
top: "100%",
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRight: "1px solid var(--line-dark)",
|
||||
borderBottom: "1px solid var(--line-dark)",
|
||||
background: "var(--surface)",
|
||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className={`inline-help ${className}`.trim()}
|
||||
tabIndex={0}
|
||||
aria-label="Show field help"
|
||||
tabIndex={-1}
|
||||
aria-label={translateText("i18n:govoplan-core.show_field_help.e3dfe98f")}
|
||||
aria-describedby={isOpen ? tooltipId : undefined}
|
||||
onMouseEnter={openWithDelay}
|
||||
onMouseLeave={close}
|
||||
onFocus={openWithDelay}
|
||||
onBlur={close}
|
||||
>
|
||||
onBlur={close}>
|
||||
|
||||
<span className="inline-help-mark" aria-hidden="true">?</span>
|
||||
</span>
|
||||
{isOpen && createPortal(
|
||||
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
|
||||
{children}
|
||||
{typeof children === "string" ? translateText(children) : children}
|
||||
<span aria-hidden="true" style={arrowStyle} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Button from "../Button";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
import FormField from "../FormField";
|
||||
import PasswordField from "../PasswordField";
|
||||
import SegmentedControl from "../SegmentedControl";
|
||||
import ToggleSwitch from "../ToggleSwitch";
|
||||
|
||||
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
||||
@@ -43,7 +44,7 @@ export type MailServerFolderLookupResult = {
|
||||
security?: MailServerSecurity | null;
|
||||
message: string;
|
||||
detected_sent_folder?: string | null;
|
||||
folders?: { name: string; flags?: string[] }[];
|
||||
folders?: {name: string;flags?: string[];}[];
|
||||
details?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
@@ -133,19 +134,19 @@ export function mailNumberOrDefault(value: string | number | null | undefined, f
|
||||
}
|
||||
|
||||
export function normalizeMailServerSecurity<TSecurity extends string = MailServerSecurityOption>(
|
||||
value: string | null | undefined,
|
||||
options: { fallback: TSecurity; allowedSecurity?: readonly TSecurity[] },
|
||||
): TSecurity {
|
||||
const allowed = options.allowedSecurity ?? (mailServerSecurityOptions as unknown as readonly TSecurity[]);
|
||||
return allowed.includes(value as TSecurity) ? (value as TSecurity) : options.fallback;
|
||||
value: string | null | undefined,
|
||||
options: {fallback: TSecurity;allowedSecurity?: readonly TSecurity[];})
|
||||
: TSecurity {
|
||||
const allowed = options.allowedSecurity ?? mailServerSecurityOptions as unknown as readonly TSecurity[];
|
||||
return allowed.includes(value as TSecurity) ? value as TSecurity : options.fallback;
|
||||
}
|
||||
|
||||
export function mailTransportCredentialsPayload(
|
||||
username: string | number | null | undefined,
|
||||
password: string | number | null | undefined,
|
||||
preserveBlankPassword: boolean,
|
||||
): { username?: string | null; password?: string | null } {
|
||||
const payload: { username?: string | null; password?: string | null } = { username: mailTextOrNull(username) };
|
||||
username: string | number | null | undefined,
|
||||
password: string | number | null | undefined,
|
||||
preserveBlankPassword: boolean)
|
||||
: {username?: string | null;password?: string | null;} {
|
||||
const payload: {username?: string | null;password?: string | null;} = { username: mailTextOrNull(username) };
|
||||
if (String(password ?? "") || !preserveBlankPassword) payload.password = mailTextOrNull(password, false);
|
||||
return payload;
|
||||
}
|
||||
@@ -157,39 +158,39 @@ function mailRecordText(record: Record<string, unknown>, key: string, fallback =
|
||||
}
|
||||
|
||||
export function mailTransportCredentialsPayloadFromRecords(
|
||||
primary: Record<string, unknown>,
|
||||
legacy: Record<string, unknown>,
|
||||
preserveBlankPassword: boolean,
|
||||
): { username?: string | null; password?: string | null } {
|
||||
primary: Record<string, unknown>,
|
||||
legacy: Record<string, unknown>,
|
||||
preserveBlankPassword: boolean)
|
||||
: {username?: string | null;password?: string | null;} {
|
||||
return mailTransportCredentialsPayload(
|
||||
mailRecordText(primary, "username", mailRecordText(legacy, "username")),
|
||||
mailRecordText(primary, "password", mailRecordText(legacy, "password")),
|
||||
preserveBlankPassword,
|
||||
preserveBlankPassword
|
||||
);
|
||||
}
|
||||
|
||||
export function mailSmtpSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
|
||||
settings: MailServerSmtpSettings,
|
||||
options: { fallbackSecurity: TSecurity; allowedSecurity?: readonly TSecurity[]; fallbackTimeoutSeconds?: number },
|
||||
): { host: string | null; port: number | null; security: TSecurity; timeout_seconds: number } {
|
||||
settings: MailServerSmtpSettings,
|
||||
options: {fallbackSecurity: TSecurity;allowedSecurity?: readonly TSecurity[];fallbackTimeoutSeconds?: number;})
|
||||
: {host: string | null;port: number | null;security: TSecurity;timeout_seconds: number;} {
|
||||
return {
|
||||
host: mailTextOrNull(settings.host),
|
||||
port: mailNumberOrNull(settings.port),
|
||||
security: normalizeMailServerSecurity(settings.security ? String(settings.security) : null, { fallback: options.fallbackSecurity, allowedSecurity: options.allowedSecurity }),
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30),
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30)
|
||||
};
|
||||
}
|
||||
|
||||
export function mailImapSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
|
||||
settings: MailServerImapSettings,
|
||||
options: { fallbackSecurity: TSecurity; allowedSecurity?: readonly TSecurity[]; fallbackTimeoutSeconds?: number },
|
||||
): { host: string | null; port: number | null; security: TSecurity; sent_folder: string; timeout_seconds: number } {
|
||||
settings: MailServerImapSettings,
|
||||
options: {fallbackSecurity: TSecurity;allowedSecurity?: readonly TSecurity[];fallbackTimeoutSeconds?: number;})
|
||||
: {host: string | null;port: number | null;security: TSecurity;sent_folder: string;timeout_seconds: number;} {
|
||||
return {
|
||||
host: mailTextOrNull(settings.host),
|
||||
port: mailNumberOrNull(settings.port),
|
||||
security: normalizeMailServerSecurity(settings.security ? String(settings.security) : null, { fallback: options.fallbackSecurity, allowedSecurity: options.allowedSecurity }),
|
||||
sent_folder: mailTextOrNull(settings.sent_folder) || "auto",
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30),
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,9 +217,9 @@ export default function MailServerSettingsPanel({
|
||||
imapPasswordSaved = false,
|
||||
imapSavedPasswordPlaceholder = "••••••••",
|
||||
imapActionDisabled = imapServerDisabled,
|
||||
smtpTestLabel = "Test SMTP",
|
||||
imapTestLabel = "Test IMAP",
|
||||
folderLookupLabel = "Folders...",
|
||||
smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981",
|
||||
imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c",
|
||||
folderLookupLabel = "i18n:govoplan-core.folders.c603ab65",
|
||||
busyAction = null,
|
||||
onTestSmtp,
|
||||
onTestImap,
|
||||
@@ -250,15 +251,15 @@ export default function MailServerSettingsPanel({
|
||||
const appendTargetFolder = append ? append.folder : stringValue(imap.sent_folder, "auto");
|
||||
const appendTargetDisabled = append ? disabled || append.folderDisabled : imapFieldsDisabled;
|
||||
const canLookupAppendFolders = Boolean(onLookupFolders) && !appendTargetDisabled;
|
||||
const appendTargetHelp = append
|
||||
? "Folder for sent-message copies. Leave as auto unless this campaign needs a different target."
|
||||
: "Folder used when this IMAP account is used for sent-message copies. Leave as auto to use the server default.";
|
||||
const appendTargetHelp = append ?
|
||||
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" :
|
||||
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e";
|
||||
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(initialSection);
|
||||
const sections: { id: MailServerSettingsSection; label: string }[] = [
|
||||
{ id: "smtp", label: "SMTP" },
|
||||
{ id: "imap", label: "IMAP" },
|
||||
{ id: "advanced", label: "Advanced" }
|
||||
];
|
||||
const sections: {id: MailServerSettingsSection;label: string;}[] = [
|
||||
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
|
||||
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" },
|
||||
{ id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }];
|
||||
|
||||
|
||||
function patchSmtpSecurity(security: MailServerSecurity) {
|
||||
const port = portForSecurityChange("smtp", smtp.port, smtpSecurity, security);
|
||||
@@ -271,153 +272,147 @@ export default function MailServerSettingsPanel({
|
||||
}
|
||||
|
||||
function patchSmtpCredentials(patch: Partial<MailServerCredentialSettings>) {
|
||||
if (onSmtpCredentialsChange) onSmtpCredentialsChange(patch);
|
||||
else onSmtpChange(patch);
|
||||
if (onSmtpCredentialsChange) onSmtpCredentialsChange(patch);else
|
||||
onSmtpChange(patch);
|
||||
}
|
||||
|
||||
function patchImapCredentials(patch: Partial<MailServerCredentialSettings>) {
|
||||
if (onImapCredentialsChange) onImapCredentialsChange(patch);
|
||||
else onImapChange(patch);
|
||||
if (onImapCredentialsChange) onImapCredentialsChange(patch);else
|
||||
onImapChange(patch);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
||||
<div className="mail-server-segmented-control" role="tablist" aria-label="Mail server settings sections">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
type="button"
|
||||
key={section.id}
|
||||
role="tab"
|
||||
aria-selected={activeSection === section.id}
|
||||
className={`mail-server-segmented-tab${activeSection === section.id ? " is-active" : ""}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
className="mail-server-segmented-control"
|
||||
size="equal"
|
||||
ariaLabel="i18n:govoplan-core.mail_server_settings_sections.40a163b7"
|
||||
value={activeSection}
|
||||
onChange={setActiveSection}
|
||||
options={sections}
|
||||
/>
|
||||
|
||||
<div className="mail-server-settings-view">
|
||||
{activeSection === "smtp" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="SMTP settings">
|
||||
{activeSection === "smtp" &&
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<div className="mail-server-field-heading">Server</div>
|
||||
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Security"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">Credentials</div>
|
||||
<FormField label="Username"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div>
|
||||
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
||||
<PasswordField
|
||||
value={stringValue(smtpCredentialValues.password)}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
saved={smtpPasswordSaved}
|
||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchSmtpCredentials({ password })}
|
||||
/>
|
||||
value={stringValue(smtpCredentialValues.password)}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
saved={smtpPasswordSaved}
|
||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchSmtpCredentials({ password })} />
|
||||
|
||||
</FormField>
|
||||
</div>
|
||||
{onTestSmtp && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "Testing..." : smtpTestLabel}</Button>
|
||||
{onTestSmtp &&
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "i18n:govoplan-core.testing.15ccc832" : smtpTestLabel}</Button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
<MailServerActionResult result={smtpTestResult} floating={floatingResults} />
|
||||
</section>
|
||||
)}
|
||||
}
|
||||
|
||||
{activeSection === "imap" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="IMAP settings">
|
||||
{activeSection === "imap" &&
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<div className="mail-server-field-heading">Server</div>
|
||||
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Security"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">Credentials</div>
|
||||
<FormField label="Username"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div>
|
||||
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
||||
<PasswordField
|
||||
value={stringValue(imapCredentialValues.password)}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
saved={imapPasswordSaved}
|
||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchImapCredentials({ password })}
|
||||
/>
|
||||
value={stringValue(imapCredentialValues.password)}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
saved={imapPasswordSaved}
|
||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchImapCredentials({ password })} />
|
||||
|
||||
</FormField>
|
||||
</div>
|
||||
{onTestImap && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "Testing..." : imapTestLabel}</Button>
|
||||
{onTestImap &&
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "i18n:govoplan-core.testing.15ccc832" : imapTestLabel}</Button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
|
||||
</section>
|
||||
)}
|
||||
}
|
||||
|
||||
{activeSection === "advanced" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="Advanced mail settings">
|
||||
{activeSection === "advanced" &&
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.advanced_mail_settings.1f69439a">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid mail-server-advanced-grid">
|
||||
{mockToggle && (
|
||||
<div className="mail-server-field-span mail-server-toggle-row">
|
||||
{mockToggle &&
|
||||
<div className="mail-server-field-span mail-server-toggle-row">
|
||||
<ToggleSwitch
|
||||
label={mockToggle.label ?? "Mock server settings"}
|
||||
checked={mockToggle.checked}
|
||||
disabled={disabled || mockToggle.disabled}
|
||||
onChange={mockToggle.onChange}
|
||||
/>
|
||||
label={mockToggle.label ?? "i18n:govoplan-core.mock_server_settings.9361d5c5"}
|
||||
checked={mockToggle.checked}
|
||||
disabled={disabled || mockToggle.disabled}
|
||||
onChange={mockToggle.onChange} />
|
||||
|
||||
</div>
|
||||
)}
|
||||
<FormField label="SMTP timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
<FormField label="IMAP timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
{append && (
|
||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||
<ToggleSwitch label="Append successfully sent messages to Sent" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
|
||||
}
|
||||
<FormField label="i18n:govoplan-core.smtp_timeout_seconds.ac87c8d2"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.imap_timeout_seconds.489af2a4"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
{append &&
|
||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||
<ToggleSwitch label="i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
|
||||
</div>
|
||||
)}
|
||||
<FormField label="Append target folder" help={appendTargetHelp}>
|
||||
}
|
||||
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help={appendTargetHelp}>
|
||||
<div className="field-with-action mail-server-folder-field">
|
||||
<input
|
||||
value={appendTargetFolder}
|
||||
disabled={appendTargetDisabled}
|
||||
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
|
||||
placeholder="auto"
|
||||
/>
|
||||
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "Looking up..." : folderLookupLabel}</Button>}
|
||||
value={appendTargetFolder}
|
||||
disabled={appendTargetDisabled}
|
||||
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
|
||||
placeholder="i18n:govoplan-core.auto.0d612c12" />
|
||||
|
||||
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : folderLookupLabel}</Button>}
|
||||
</div>
|
||||
</FormField>
|
||||
{canLookupAppendFolders && <MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled || appendTargetDisabled} onUseDetected={onUseDetectedFolder} />}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function MailServerActionResult({ result, floating = false }: { result: MailServerConnectionTestResult | null | undefined; floating?: boolean }) {
|
||||
export function MailServerActionResult({ result, floating = false }: {result: MailServerConnectionTestResult | null | undefined;floating?: boolean;}) {
|
||||
if (!result) return null;
|
||||
const authenticated = result.details?.authenticated;
|
||||
return (
|
||||
<DismissibleAlert tone={result.ok ? "success" : "danger"} resetKey={`${result.ok}:${result.message}`} floating={floating}>
|
||||
{result.message}
|
||||
{result.ok && typeof authenticated === "boolean" && (
|
||||
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
|
||||
)}
|
||||
</DismissibleAlert>
|
||||
);
|
||||
{result.ok && typeof authenticated === "boolean" &&
|
||||
<span> i18n:govoplan-core.authentication.e665f157 {authenticated ? "i18n:govoplan-core.credentials_accepted.ae8e2629" : "i18n:govoplan-core.not_used.487f8bcd"}.</span>
|
||||
}
|
||||
</DismissibleAlert>);
|
||||
|
||||
}
|
||||
|
||||
export function MailServerFolderLookupResultView({
|
||||
result,
|
||||
disabled = false,
|
||||
onUseDetected
|
||||
}: {
|
||||
result: MailServerFolderLookupResult | null | undefined;
|
||||
disabled?: boolean;
|
||||
onUseDetected?: () => void;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;}) {
|
||||
if (!result) return null;
|
||||
if (!result.ok) {
|
||||
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
|
||||
@@ -427,21 +422,21 @@ export function MailServerFolderLookupResultView({
|
||||
return (
|
||||
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
||||
<p>{result.message}</p>
|
||||
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "-"}</strong></p>
|
||||
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
|
||||
{folders.length > 0 && (
|
||||
<div className="mail-server-folder-chip-list">
|
||||
{folders.slice(0, 12).map((folder) => (
|
||||
<span className="mail-server-folder-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
|
||||
))}
|
||||
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p>
|
||||
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>}
|
||||
{folders.length > 0 &&
|
||||
<div className="mail-server-folder-chip-list">
|
||||
{folders.slice(0, 12).map((folder) =>
|
||||
<span className="mail-server-folder-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
|
||||
)}
|
||||
{folders.length > 12 && <span className="mail-server-folder-chip">+{folders.length - 12} more</span>}
|
||||
</div>
|
||||
)}
|
||||
</DismissibleAlert>
|
||||
);
|
||||
}
|
||||
</DismissibleAlert>);
|
||||
|
||||
}
|
||||
|
||||
function SecuritySelect({ value, disabled, onChange }: { value: string; disabled: boolean; onChange: (value: MailServerSecurity) => void }) {
|
||||
function SecuritySelect({ value, disabled, onChange }: {value: string;disabled: boolean;onChange: (value: MailServerSecurity) => void;}) {
|
||||
return <select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import Card from "../components/Card";
|
||||
|
||||
export default function PlaceholderPage({ title }: { title: string }) {
|
||||
export default function PlaceholderPage({ title }: {title: string;}) {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>{title}</h1>
|
||||
<p>This module is prepared but not implemented yet.</p>
|
||||
<p>i18n:govoplan-core.this_module_is_prepared_but_not_implemented_yet.6e6449ee</p>
|
||||
</div>
|
||||
<Card>
|
||||
<p className="muted">Next passes will add functionality here.</p>
|
||||
<p className="muted">i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -11,15 +11,15 @@ export default function LoginModal({
|
||||
settings,
|
||||
onClose,
|
||||
onLogin,
|
||||
title = "Sign in",
|
||||
title = "i18n:govoplan-core.sign_in.ada2e9e9",
|
||||
message
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
onClose: () => void;
|
||||
onLogin: (response: LoginResponse) => void;
|
||||
title?: string;
|
||||
message?: string;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;onClose: () => void;onLogin: (response: LoginResponse) => void;title?: string;message?: string;}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -46,23 +46,23 @@ export default function LoginModal({
|
||||
open
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" form={formId} variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" onClick={onClose}>i18n:govoplan-core.cancel.77dfd213</Button>
|
||||
<Button type="submit" form={formId} variant="primary" disabled={busy}>{busy ? "i18n:govoplan-core.signing_in.c66b2adc" : "i18n:govoplan-core.sign_in.ada2e9e9"}</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">
|
||||
<FormField label="i18n:govoplan-core.email.84add5b2">
|
||||
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Password">
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
||||
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
|
||||
</FormField>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
@@ -7,48 +7,48 @@ export default function PublicLandingPage({
|
||||
settings,
|
||||
maintenanceMode,
|
||||
onLogin
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
maintenanceMode?: { enabled: boolean; message?: string | null };
|
||||
onLogin: (response: LoginResponse) => void;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;maintenanceMode?: {enabled: boolean;message?: string | null;};onLogin: (response: LoginResponse) => void;}) {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="public-landing">
|
||||
<section className="public-card">
|
||||
<div className="public-kicker">GovOPlaN</div>
|
||||
<h1>Modular planning tools with controlled access.</h1>
|
||||
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
|
||||
<h1>i18n:govoplan-core.modular_planning_tools_with_controlled_access.1d6b0743</h1>
|
||||
<p>
|
||||
Sign in to open the modules available to your tenant and role.
|
||||
i18n:govoplan-core.sign_in_to_open_the_modules_available_to_your_te.8bb7dab4
|
||||
</p>
|
||||
|
||||
{maintenanceMode?.enabled && (
|
||||
<div className="public-maintenance alert warning">
|
||||
<strong>Maintenance mode is active.</strong>
|
||||
<span>{maintenanceMode.message || "Only users with maintenance access can use the system right now."}</span>
|
||||
{maintenanceMode?.enabled &&
|
||||
<div className="public-maintenance alert warning">
|
||||
<strong>i18n:govoplan-core.maintenance_mode_is_active.70f918d9</strong>
|
||||
<span>{maintenanceMode.message || "i18n:govoplan-core.only_users_with_maintenance_access_can_use_the_s.5566264f"}</span>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
<div className="public-actions">
|
||||
<Button variant="primary" onClick={() => setLoginOpen(true)}>Sign in</Button>
|
||||
<Button variant="primary" onClick={() => setLoginOpen(true)}>i18n:govoplan-core.sign_in.ada2e9e9</Button>
|
||||
</div>
|
||||
|
||||
<div className="public-footnote">
|
||||
Access is restricted. Sign in to open available modules and administration.
|
||||
i18n:govoplan-core.access_is_restricted_sign_in_to_open_available_m.2a47a549
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loginOpen && (
|
||||
<LoginModal
|
||||
settings={settings}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onLogin={(response) => {
|
||||
onLogin(response);
|
||||
setLoginOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{loginOpen &&
|
||||
<LoginModal
|
||||
settings={settings}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onLogin={(response) => {
|
||||
onLogin(response);
|
||||
setLoginOpen(false);
|
||||
}} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,149 +1,46 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Card from "../../components/Card";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { apiFetch, isApiError } from "../../api/client";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import { usePlatformModules } from "../../platform/ModuleContext";
|
||||
import type { ApiSettings } from "../../types";
|
||||
|
||||
type DashboardCampaign = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
external_id?: string | null;
|
||||
status?: string | null;
|
||||
updated_at?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
type CampaignListResponse = DashboardCampaign[] | { campaigns?: DashboardCampaign[]; items?: DashboardCampaign[]; results?: DashboardCampaign[] };
|
||||
|
||||
export default function DashboardPage({ settings }: { settings: ApiSettings }) {
|
||||
export default function DashboardPage() {
|
||||
const modules = usePlatformModules();
|
||||
const campaignsInstalled = modules.some((module) => module.id === "campaigns");
|
||||
const [campaigns, setCampaigns] = useState<DashboardCampaign[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignsInstalled) {
|
||||
setCampaigns([]);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns")
|
||||
.then((response) => {
|
||||
if (!cancelled) setCampaigns(campaignsFromResponse(response));
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (isApiError(reason, 403, 404)) {
|
||||
setCampaigns([]);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [campaignsInstalled, settings]);
|
||||
|
||||
const statusCounts = useMemo(() => countByStatus(campaigns), [campaigns]);
|
||||
const recentCampaigns = useMemo(
|
||||
() => [...campaigns].sort((left, right) => timestamp(right) - timestamp(left)).slice(0, 5),
|
||||
[campaigns],
|
||||
);
|
||||
const activeCampaigns = (statusCounts.active ?? 0) + (statusCounts.draft ?? 0);
|
||||
const completedCampaigns = (statusCounts.completed ?? 0) + (statusCounts.sent ?? 0);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Tenant overview across installed modules and accessible campaigns.</p>
|
||||
<PageTitle>i18n:govoplan-core.dashboard.d87f47b4</PageTitle>
|
||||
<p>Install and enable the dashboard module to make this page configurable.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="warning" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Installed modules" value={modules.length} tone="info" detail={modules.length ? moduleLabels(modules).join(", ") : "Core only"} />
|
||||
<MetricCard label="Campaigns" value={campaignsInstalled ? campaigns.length : "—"} tone="neutral" detail={campaignsInstalled ? "Accessible to you" : "Campaign module not installed"} />
|
||||
<MetricCard label="Active drafts" value={campaignsInstalled ? activeCampaigns : "—"} tone="warning" detail="Draft or active" />
|
||||
<MetricCard label="Completed" value={campaignsInstalled ? completedCampaigns : "—"} tone="good" detail="Completed or sent" />
|
||||
<MetricCard label="i18n:govoplan-core.installed_modules.32b3e799" value={modules.length} tone="info" detail={modules.length ? moduleLabels(modules).join(", ") : "i18n:govoplan-core.core_only.d46ce7d9"} />
|
||||
<MetricCard label="Dashboard module" value="not installed" tone="neutral" detail="Core fallback is active." />
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Recent campaigns" collapsible>
|
||||
<LoadingFrame loading={loading} label="Loading campaigns...">
|
||||
{!campaignsInstalled && <p className="muted">Install the Campaign module to show campaign activity here.</p>}
|
||||
{campaignsInstalled && recentCampaigns.length === 0 && <p className="muted">No accessible campaigns found.</p>}
|
||||
{recentCampaigns.length > 0 && (
|
||||
<dl className="detail-list">
|
||||
{recentCampaigns.map((campaign) => (
|
||||
<div key={campaign.id}>
|
||||
<dt><StatusBadge status={campaign.status || "draft"} /></dt>
|
||||
<dd>
|
||||
<strong>{campaign.name || campaign.external_id || campaign.id}</strong>
|
||||
<span className="muted"> · {formatDashboardDate(campaign.updated_at ?? campaign.updatedAt)}</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
|
||||
<Card title="Installed modules" collapsible>
|
||||
{modules.length === 0 ? <p className="muted">No feature modules are active.</p> : (
|
||||
<dl className="detail-list">
|
||||
{modules.map((module) => (
|
||||
<div key={module.id}>
|
||||
<Card title="i18n:govoplan-core.installed_modules.32b3e799">
|
||||
{modules.length === 0 ? <p className="muted">i18n:govoplan-core.no_feature_modules_are_active.e9c2d936</p> :
|
||||
<dl className="detail-list">
|
||||
{modules.map((module) =>
|
||||
<div key={module.id}>
|
||||
<dt>{module.id}</dt>
|
||||
<dd><strong>{module.label}</strong><span className="muted"> · v{module.version}</span></dd>
|
||||
<dd><strong>{module.label}</strong><span className="muted"> i18n:govoplan-core.v.26c12f1e{module.version}</span></dd>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
}
|
||||
</Card>
|
||||
<Card title="Home">
|
||||
<p className="muted">This minimal core home is only shown while no dashboard WebUI module is available. Feature modules own their pages and can expose dashboard widgets once the dashboard module is installed.</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function campaignsFromResponse(response: CampaignListResponse): DashboardCampaign[] {
|
||||
if (Array.isArray(response)) return response;
|
||||
return response.campaigns ?? response.items ?? response.results ?? [];
|
||||
}
|
||||
|
||||
function countByStatus(campaigns: DashboardCampaign[]): Record<string, number> {
|
||||
return campaigns.reduce<Record<string, number>>((counts, campaign) => {
|
||||
const status = String(campaign.status || "draft").toLowerCase();
|
||||
counts[status] = (counts[status] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function timestamp(campaign: DashboardCampaign): number {
|
||||
const value = campaign.updated_at ?? campaign.updatedAt ?? "";
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatDashboardDate(value?: string | null): string {
|
||||
if (!value) return "not updated";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
|
||||
}
|
||||
|
||||
function moduleLabels(modules: Array<{ label: string }>): string[] {
|
||||
function moduleLabels(modules: Array<{label: string;}>): string[] {
|
||||
return modules.map((module) => module.label).slice(0, 4);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { i18nMessage } from "../../i18n/LanguageContext";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
@@ -8,15 +9,21 @@ import {
|
||||
type PrivacyRetentionPolicy,
|
||||
type PrivacyRetentionPolicyFieldKey,
|
||||
type PrivacyRetentionPolicyPatch,
|
||||
type PrivacyRetentionPolicyScope
|
||||
} from "../../api/admin";
|
||||
type PrivacyRetentionPolicyScope } from
|
||||
"../../api/admin";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { PolicyRow, PolicySection, PolicyTable } from "../../components/PolicyTable";
|
||||
import type { PolicySourcePathItem } from "../../components/PolicySourcePath";
|
||||
import FieldLabel from "../../components/help/FieldLabel";
|
||||
import FormField from "../../components/FormField";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||
import {
|
||||
privacyRetentionLocalAllowsLowerLevel,
|
||||
privacyRetentionParentAllowsField } from
|
||||
"./policyLogic";
|
||||
|
||||
export type RetentionPolicyTargetOption = {
|
||||
id: string;
|
||||
@@ -47,11 +54,11 @@ type RetentionPolicyEditorProps = {
|
||||
};
|
||||
|
||||
type DayKey =
|
||||
| "raw_campaign_json_retention_days"
|
||||
| "generated_eml_retention_days"
|
||||
| "stored_report_detail_retention_days"
|
||||
| "mock_mailbox_retention_days"
|
||||
| "audit_detail_retention_days";
|
||||
"raw_campaign_json_retention_days" |
|
||||
"generated_eml_retention_days" |
|
||||
"stored_report_detail_retention_days" |
|
||||
"mock_mailbox_retention_days" |
|
||||
"audit_detail_retention_days";
|
||||
|
||||
type FieldDefinition = {
|
||||
key: PrivacyRetentionPolicyFieldKey;
|
||||
@@ -87,22 +94,22 @@ const defaultPrivacyPolicy: PrivacyRetentionPolicy = {
|
||||
};
|
||||
|
||||
const fieldDefinitions: FieldDefinition[] = [
|
||||
{ key: "store_raw_campaign_json", label: "Raw campaign JSON", kind: "raw-json" },
|
||||
{ key: "raw_campaign_json_retention_days", label: "Raw campaign JSON days", kind: "days" },
|
||||
{ key: "generated_eml_retention_days", label: "Generated EML days", kind: "days" },
|
||||
{ key: "stored_report_detail_retention_days", label: "Stored report detail days", kind: "days" },
|
||||
{ key: "mock_mailbox_retention_days", label: "Mock mailbox days", kind: "days", systemOnly: true },
|
||||
{ key: "audit_detail_retention_days", label: "Audit detail days", kind: "days" },
|
||||
{ key: "audit_detail_level", label: "Audit detail level", kind: "audit" }
|
||||
];
|
||||
{ key: "store_raw_campaign_json", label: "i18n:govoplan-core.raw_campaign_json.4ca2265b", kind: "raw-json" },
|
||||
{ key: "raw_campaign_json_retention_days", label: "i18n:govoplan-core.raw_campaign_json_days.48805a59", kind: "days" },
|
||||
{ key: "generated_eml_retention_days", label: "i18n:govoplan-core.generated_eml_days.bcb273d9", kind: "days" },
|
||||
{ key: "stored_report_detail_retention_days", label: "i18n:govoplan-core.stored_report_detail_days.296f6dab", kind: "days" },
|
||||
{ key: "mock_mailbox_retention_days", label: "i18n:govoplan-core.mock_mailbox_days.a0cf3209", kind: "days", systemOnly: true },
|
||||
{ key: "audit_detail_retention_days", label: "i18n:govoplan-core.audit_detail_days.21c84dd1", kind: "days" },
|
||||
{ key: "audit_detail_level", label: "i18n:govoplan-core.audit_detail_level.b0565260", kind: "audit" }];
|
||||
|
||||
|
||||
export function RetentionPolicyScopeManager({
|
||||
settings,
|
||||
scopeType,
|
||||
scopeId = null,
|
||||
targetOptions = [],
|
||||
targetLabel = "Target",
|
||||
title = "Retention policy",
|
||||
targetLabel = "i18n:govoplan-core.target.61ad50a9",
|
||||
title = "i18n:govoplan-core.retention_policy.962fb418",
|
||||
description,
|
||||
canWrite,
|
||||
locked = false
|
||||
@@ -112,10 +119,10 @@ export function RetentionPolicyScopeManager({
|
||||
const targetSelectionRequired = requiresTarget && !scopeId;
|
||||
const hasSelectableTarget = targetOptions.length > 0;
|
||||
const activeScopeId = scopeId || selectedTargetId || null;
|
||||
const defaultDescription = scopeType === "system"
|
||||
? "System retention defaults and the fields lower levels may override."
|
||||
: "Local retention limits for this scope. Values inherit from the parent unless explicitly narrowed.";
|
||||
const targetEmptyText = `No ${pluralizeLabel(targetLabel.toLowerCase())} available`;
|
||||
const defaultDescription = scopeType === "system" ?
|
||||
"i18n:govoplan-core.system_retention_defaults_and_the_fields_lower_l.90a9e923" :
|
||||
"i18n:govoplan-core.local_retention_limits_for_this_scope_values_inh.6114dade";
|
||||
const targetEmptyText = i18nMessage("i18n:govoplan-core.no_value_available", { value0: targetPluralLabel(scopeType, targetLabel) });
|
||||
|
||||
useEffect(() => {
|
||||
if (scopeId) {
|
||||
@@ -131,44 +138,45 @@ export function RetentionPolicyScopeManager({
|
||||
|
||||
return (
|
||||
<div className="retention-policy-manager">
|
||||
{targetSelectionRequired && (
|
||||
<Card title={`${targetLabel} scope`}>
|
||||
{targetSelectionRequired &&
|
||||
<Card title={i18nMessage("i18n:govoplan-core.value_scope", { value0: targetLabel })}>
|
||||
<div className="retention-policy-target-row">
|
||||
<FormField label={targetLabel}>
|
||||
<select value={selectedTargetId} disabled={!hasSelectableTarget} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
{!hasSelectableTarget && <option value="">{targetEmptyText}</option>}
|
||||
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? `${option.label} (${option.secondary})` : option.label}</option>)}
|
||||
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? i18nMessage("i18n:govoplan-core.value_value.c189e8bc", { value0: option.label, value1: option.secondary }) : option.label}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{(!requiresTarget || Boolean(activeScopeId)) && (
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
scopeId={activeScopeId}
|
||||
title={title}
|
||||
description={description ?? defaultDescription}
|
||||
canWrite={canWrite}
|
||||
locked={locked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{(!requiresTarget || Boolean(activeScopeId)) &&
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
scopeId={activeScopeId}
|
||||
title={title}
|
||||
description={description ?? defaultDescription}
|
||||
canWrite={canWrite}
|
||||
locked={locked} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function pluralizeLabel(label: string): string {
|
||||
if (label.endsWith("s")) return label;
|
||||
if (label.endsWith("y")) return `${label.slice(0, -1)}ies`;
|
||||
return `${label}s`;
|
||||
function targetPluralLabel(scopeType: PrivacyRetentionPolicyScope, fallback: string): string {
|
||||
if (scopeType === "user") return "i18n:govoplan-core.users";
|
||||
if (scopeType === "group") return "i18n:govoplan-core.groups";
|
||||
if (scopeType === "campaign") return "i18n:govoplan-core.campaigns.01a23a28";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function RetentionPolicyEditor({
|
||||
settings,
|
||||
scopeType,
|
||||
scopeId = null,
|
||||
title = "Retention policy",
|
||||
title = "i18n:govoplan-core.retention_policy.962fb418",
|
||||
description,
|
||||
canWrite,
|
||||
locked = false
|
||||
@@ -177,6 +185,7 @@ export function RetentionPolicyEditor({
|
||||
const [effectivePolicy, setEffectivePolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
||||
const [parentPolicy, setParentPolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
||||
const [effectivePolicySources, setEffectivePolicySources] = useState<PolicySourcePathItem[]>([]);
|
||||
const [savedPolicyKey, setSavedPolicyKey] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -191,8 +200,18 @@ export function RetentionPolicyEditor({
|
||||
const activeParentPolicy = parentPolicy ? normalizeFullPolicy(parentPolicy) : null;
|
||||
const activeFullPolicy = normalizeFullPolicy({ ...defaultPrivacyPolicy, ...policy, allow_lower_level_limits: { ...defaultAllowLowerLevelLimits, ...(policy.allow_lower_level_limits ?? {}) } });
|
||||
const visibleFields = useMemo(() => fieldDefinitions.filter((field) => isSystem || !field.systemOnly), [isSystem]);
|
||||
const policyDirty = scopeReady && Boolean(savedPolicyKey) && policyDraftKey(policy, isSystem) !== savedPolicyKey;
|
||||
|
||||
useEffect(() => { void loadPolicy(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId]);
|
||||
useUnsavedDraftGuard({
|
||||
dirty: policyDirty,
|
||||
onSave: savePolicy,
|
||||
onDiscard: () => {
|
||||
if (!savedPolicyKey) return;
|
||||
setPolicy(JSON.parse(savedPolicyKey) as PrivacyRetentionPolicyPatch);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {void loadPolicy();}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId]);
|
||||
|
||||
async function loadPolicy() {
|
||||
setError("");
|
||||
@@ -207,12 +226,15 @@ export function RetentionPolicyEditor({
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getPrivacyRetentionPolicy(settings, scopeType, scopeId);
|
||||
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
||||
const loadedPolicy = isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy);
|
||||
setPolicy(loadedPolicy);
|
||||
setSavedPolicyKey(policyDraftKey(loadedPolicy, isSystem));
|
||||
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
||||
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
||||
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||
} catch (err) {
|
||||
setPolicy({});
|
||||
setSavedPolicyKey("");
|
||||
setEffectivePolicy(null);
|
||||
setParentPolicy(null);
|
||||
setEffectivePolicySources([]);
|
||||
@@ -222,21 +244,25 @@ export function RetentionPolicyEditor({
|
||||
}
|
||||
}
|
||||
|
||||
async function savePolicy() {
|
||||
if (!scopeReady) return;
|
||||
async function savePolicy(): Promise<boolean> {
|
||||
if (!scopeReady) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const payload = isSystem ? normalizeFullPolicy(policy) : normalizePatchForSave(policy, activeParentPolicy, scopeType);
|
||||
const response = await updatePrivacyRetentionPolicy(settings, scopeType, payload, scopeId);
|
||||
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
||||
const savedPolicy = isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy);
|
||||
setPolicy(savedPolicy);
|
||||
setSavedPolicyKey(policyDraftKey(savedPolicy, isSystem));
|
||||
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
||||
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
||||
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||
setSuccess("Retention policy saved.");
|
||||
setSuccess("i18n:govoplan-core.retention_policy_saved.eb577758");
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -260,7 +286,7 @@ export function RetentionPolicyEditor({
|
||||
|
||||
function setRetentionDays(key: DayKey, value: string) {
|
||||
const trimmed = value.trim();
|
||||
patchPolicy({ [key]: trimmed === "" ? (isSystem ? null : undefined) : Math.max(0, Number(trimmed) || 0) });
|
||||
patchPolicy({ [key]: trimmed === "" ? isSystem ? null : undefined : Math.max(0, Number(trimmed) || 0) });
|
||||
}
|
||||
|
||||
function setAuditDetail(value: AuditDetailValue) {
|
||||
@@ -272,84 +298,89 @@ export function RetentionPolicyEditor({
|
||||
}
|
||||
|
||||
function parentAllows(key: PrivacyRetentionPolicyFieldKey): boolean {
|
||||
return !activeParentPolicy || activeParentPolicy.allow_lower_level_limits[key] !== false;
|
||||
return privacyRetentionParentAllowsField(activeParentPolicy, key);
|
||||
}
|
||||
|
||||
function localAllowsLower(key: PrivacyRetentionPolicyFieldKey): boolean {
|
||||
if (isSystem) return activeFullPolicy.allow_lower_level_limits[key] !== false;
|
||||
const localValue = policy.allow_lower_level_limits?.[key];
|
||||
if (localValue !== undefined) return localValue && parentAllows(key);
|
||||
return parentAllows(key);
|
||||
return privacyRetentionLocalAllowsLowerLevel(isSystem ? activeFullPolicy : policy, activeParentPolicy, key, isSystem);
|
||||
}
|
||||
|
||||
const localPolicySummary = useMemo(() => isSystem ? "System baseline" : localPolicyCount(policy), [isSystem, policy]);
|
||||
const defaultDescription = isSystem
|
||||
? "Set concrete system retention values. Blank day fields mean unlimited retention."
|
||||
: "Blank values inherit. Numeric values may only shorten the inherited retention period; audit detail may only become more restrictive.";
|
||||
const localPolicySummary = useMemo(() => isSystem ? "i18n:govoplan-core.system_baseline.219ce334" : localPolicyCount(policy), [isSystem, policy]);
|
||||
const defaultDescription = isSystem ?
|
||||
"i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627" :
|
||||
"i18n:govoplan-core.blank_values_inherit_numeric_values_may_only_sho.1a96ce70";
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={title}
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "Loading…" : "Reload"}</Button>
|
||||
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "Saving…" : "Save policy"}</Button>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "i18n:govoplan-core.loading.33ce4174" : "i18n:govoplan-core.reload.cce71553"}</Button>
|
||||
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_policy.77d67ce3"}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
}>
|
||||
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-core.loading_retention_policy.dcd30fb6">
|
||||
<div className="retention-policy-editor">
|
||||
<p className="muted small-note retention-policy-description">{description ?? defaultDescription}</p>
|
||||
{!isSystem && <p className="muted small-note retention-policy-description">Mock mailbox retention is currently managed at system level because mock mailbox records do not carry tenant or campaign ownership metadata.</p>}
|
||||
{!isSystem && <p className="muted small-note retention-policy-description">i18n:govoplan-core.mock_mailbox_retention_is_currently_managed_at_s.8c6a8ec7</p>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
|
||||
<section className="retention-policy-section">
|
||||
<div className="subsection-heading split">
|
||||
<h3>{isSystem ? "System policy" : "Local policy"}</h3>
|
||||
<span className="muted small-note">{localPolicySummary}</span>
|
||||
</div>
|
||||
<div className={`retention-policy-table policy-table${showEffectiveColumn ? " with-effective-column" : ""}${showAllowColumn ? " with-allow-column" : ""}`}>
|
||||
<div className="retention-policy-row policy-row retention-policy-row-header policy-row-header">
|
||||
<span>Field</span>
|
||||
<span>{isSystem ? "Value" : "Local setting"}</span>
|
||||
{showEffectiveColumn && <span>Effective policy</span>}
|
||||
{showAllowColumn && <span>Lower levels</span>}
|
||||
</div>
|
||||
<PolicySection
|
||||
className="retention-policy-section"
|
||||
title={isSystem ? "i18n:govoplan-core.system_policy.3e1aac4b" : "i18n:govoplan-core.local_policy.942f8665"}
|
||||
summary={<span className="muted small-note">{localPolicySummary}</span>}>
|
||||
<PolicyTable
|
||||
className="retention-policy-table"
|
||||
rowClassName="retention-policy-row"
|
||||
headerClassName="retention-policy-row-header"
|
||||
fieldLabel="i18n:govoplan-core.field.c326a466"
|
||||
settingLabel={isSystem ? "i18n:govoplan-core.value.8dce170d" : "i18n:govoplan-core.local_setting.967607a9"}
|
||||
effectiveLabel="i18n:govoplan-core.effective_policy.feedb950"
|
||||
lowerLevelLabel="i18n:govoplan-core.lower_levels.940821ee"
|
||||
showEffectiveColumn={showEffectiveColumn}
|
||||
showAllowColumn={showAllowColumn}>
|
||||
{visibleFields.map((field) => {
|
||||
const fieldLocked = !parentAllows(field.key);
|
||||
const fieldDisabled = disabled || fieldLocked;
|
||||
return (
|
||||
<div className="retention-policy-row policy-row" key={field.key}>
|
||||
<div className="retention-policy-field-label policy-field-label">
|
||||
<strong>{field.label}</strong>
|
||||
{fieldLocked && <small>Locked by parent policy</small>}
|
||||
{field.systemOnly && <small>System-level cleanup scope</small>}
|
||||
</div>
|
||||
<div className="policy-control">{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}</div>
|
||||
{showEffectiveColumn && (
|
||||
<div className="retention-policy-effective-cell policy-effective-cell">
|
||||
<FieldLabel className="retention-policy-effective-value policy-effective-value" help={retentionPolicyPathHelp(field, effectivePolicySources, scopeType)}>
|
||||
{retentionFieldValue(field, effectivePolicy, loading)}
|
||||
</FieldLabel>
|
||||
</div>
|
||||
)}
|
||||
{showAllowColumn && (
|
||||
<ToggleSwitch
|
||||
checked={localAllowsLower(field.key)}
|
||||
disabled={disabled || fieldLocked}
|
||||
onChange={(checked) => setAllowLowerLevelLimit(field.key, checked)}
|
||||
label="Allow override"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
<PolicyRow
|
||||
key={field.key}
|
||||
className="retention-policy-row"
|
||||
labelClassName="retention-policy-field-label"
|
||||
effectiveCellClassName="retention-policy-effective-cell"
|
||||
effectiveClassName="retention-policy-effective-value"
|
||||
label={field.label}
|
||||
note={
|
||||
<>
|
||||
{fieldLocked && <small>i18n:govoplan-core.locked_by_parent_policy.66f587c1</small>}
|
||||
{field.systemOnly && <small>i18n:govoplan-core.system_level_cleanup_scope.2bce57b5</small>}
|
||||
</>
|
||||
}
|
||||
control={renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}
|
||||
effective={showEffectiveColumn ? retentionFieldValue(field, effectivePolicy, loading) : undefined}
|
||||
effectiveHelp={showEffectiveColumn ? retentionPolicyPathHelp(field, effectivePolicySources, scopeType) : undefined}
|
||||
allowControl={showAllowColumn ?
|
||||
<ToggleSwitch
|
||||
checked={localAllowsLower(field.key)}
|
||||
disabled={disabled || fieldLocked}
|
||||
onChange={(checked) => setAllowLowerLevelLimit(field.key, checked)}
|
||||
label="i18n:govoplan-core.allow_override.ffa6e9a0" /> :
|
||||
undefined} />);
|
||||
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</PolicyTable>
|
||||
</PolicySection>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
</LoadingFrame>
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
function policyDraftKey(policy: PrivacyRetentionPolicyPatch, isSystem: boolean): string {
|
||||
return JSON.stringify(isSystem ? normalizeFullPolicy(policy) : normalizePatch(policy));
|
||||
}
|
||||
|
||||
type PolicySourceItem = {
|
||||
@@ -362,16 +393,16 @@ function retentionPolicyPathHelp(field: FieldDefinition, sources: PolicySourcePa
|
||||
return <PolicyPathHelp lines={retentionPolicyPathLines(field, items)} />;
|
||||
}
|
||||
|
||||
function PolicyPathHelp({ lines }: { lines: string[] }) {
|
||||
function PolicyPathHelp({ lines }: {lines: string[];}) {
|
||||
return (
|
||||
<span className="policy-path-help">
|
||||
{lines.map((line, index) => <span className="policy-path-help-line" key={`${line}-${index}`}>{line}</span>)}
|
||||
</span>
|
||||
);
|
||||
</span>);
|
||||
|
||||
}
|
||||
|
||||
function retentionPolicyPathLines(field: FieldDefinition, items: PolicySourceItem[]): string[] {
|
||||
if (items.length === 0) return ["System: Default"];
|
||||
if (items.length === 0) return ["i18n:govoplan-core.system_default.1f06f3ed"];
|
||||
const lines: string[] = [];
|
||||
for (const [index, item] of items.entries()) {
|
||||
const sourcePolicy = asRecord(item.policy);
|
||||
@@ -384,18 +415,18 @@ function retentionPolicyPathLines(field: FieldDefinition, items: PolicySourceIte
|
||||
}
|
||||
|
||||
function retentionSourceValue(field: FieldDefinition, sourcePolicy: Record<string, unknown>, isSystem: boolean): string {
|
||||
if (Object.keys(sourcePolicy).length === 0) return isSystem ? "Default" : "Inherit";
|
||||
if (!isSystem && !(field.key in sourcePolicy)) return "Inherit";
|
||||
if (Object.keys(sourcePolicy).length === 0) return isSystem ? "i18n:govoplan-core.default.808d7dca" : "i18n:govoplan-core.inherit.18f99833";
|
||||
if (!isSystem && !(field.key in sourcePolicy)) return "i18n:govoplan-core.inherit.18f99833";
|
||||
if (field.kind === "raw-json") {
|
||||
const value = sourcePolicy.store_raw_campaign_json;
|
||||
return value === false ? "Disabled" : value === true ? "Stored" : "Inherit";
|
||||
return value === false ? "i18n:govoplan-core.disabled.f4f4473d" : value === true ? "i18n:govoplan-core.stored.b2b53ee4" : "i18n:govoplan-core.inherit.18f99833";
|
||||
}
|
||||
if (field.kind === "audit") {
|
||||
const value = sourcePolicy.audit_detail_level;
|
||||
return value === "full" || value === "redacted" || value === "minimal" ? auditDetailLabel(value) : "Inherit";
|
||||
return value === "full" || value === "redacted" || value === "minimal" ? auditDetailLabel(value) : "i18n:govoplan-core.inherit.18f99833";
|
||||
}
|
||||
const value = sourcePolicy[field.key];
|
||||
return typeof value === "number" ? daysLabel(value) : value === null && isSystem ? daysLabel(null) : "Inherit";
|
||||
return typeof value === "number" ? daysLabel(value) : value === null && isSystem ? daysLabel(null) : "i18n:govoplan-core.inherit.18f99833";
|
||||
}
|
||||
|
||||
function normalizePolicySourceItems(items: PolicySourcePathItem[]): PolicySourceItem[] {
|
||||
@@ -414,23 +445,23 @@ function policyPathPrefix(index: number): string {
|
||||
}
|
||||
|
||||
function retentionFieldValue(field: FieldDefinition, policy: PrivacyRetentionPolicy | null, loading: boolean): string {
|
||||
if (!policy) return loading ? "Loading..." : "Unavailable";
|
||||
if (field.kind === "raw-json") return policy.store_raw_campaign_json ? "Stored" : "Disabled";
|
||||
if (!policy) return loading ? "i18n:govoplan-core.loading.b04ba49f" : "i18n:govoplan-core.unavailable.2c9c1f79";
|
||||
if (field.kind === "raw-json") return policy.store_raw_campaign_json ? "i18n:govoplan-core.stored.b2b53ee4" : "i18n:govoplan-core.disabled.f4f4473d";
|
||||
if (field.kind === "audit") return auditDetailLabel(policy.audit_detail_level);
|
||||
return daysLabel(policy[field.key as DayKey]);
|
||||
}
|
||||
|
||||
function renderFieldControl(
|
||||
field: FieldDefinition,
|
||||
fullPolicy: PrivacyRetentionPolicy,
|
||||
patchPolicy: PrivacyRetentionPolicyPatch,
|
||||
isSystem: boolean,
|
||||
disabled: boolean,
|
||||
setRawJson: (value: RawJsonValue | "store" | "disabled") => void,
|
||||
setRetentionDays: (key: DayKey, value: string) => void,
|
||||
setAuditDetail: (value: AuditDetailValue) => void,
|
||||
parentPolicy: PrivacyRetentionPolicy | null
|
||||
) {
|
||||
field: FieldDefinition,
|
||||
fullPolicy: PrivacyRetentionPolicy,
|
||||
patchPolicy: PrivacyRetentionPolicyPatch,
|
||||
isSystem: boolean,
|
||||
disabled: boolean,
|
||||
setRawJson: (value: RawJsonValue | "store" | "disabled") => void,
|
||||
setRetentionDays: (key: DayKey, value: string) => void,
|
||||
setAuditDetail: (value: AuditDetailValue) => void,
|
||||
parentPolicy: PrivacyRetentionPolicy | null)
|
||||
{
|
||||
if (field.kind === "raw-json") {
|
||||
if (isSystem) {
|
||||
return <RawJsonSystemSelect value={fullPolicy.store_raw_campaign_json ? "store" : "disabled"} disabled={disabled} onChange={setRawJson} />;
|
||||
@@ -445,33 +476,33 @@ function renderFieldControl(
|
||||
<RetentionDaysField
|
||||
value={isSystem ? fullPolicy[field.key as DayKey] : patchPolicy[field.key as DayKey]}
|
||||
disabled={disabled}
|
||||
placeholder={isSystem ? "Unlimited" : "Inherit"}
|
||||
placeholder={isSystem ? "i18n:govoplan-core.unlimited.b8bef37b" : "i18n:govoplan-core.inherit.18f99833"}
|
||||
max={parentDayLimit}
|
||||
onChange={(value) => setRetentionDays(field.key as DayKey, value)}
|
||||
/>
|
||||
);
|
||||
onChange={(value) => setRetentionDays(field.key as DayKey, value)} />);
|
||||
|
||||
|
||||
}
|
||||
|
||||
function RawJsonSystemSelect({ value, disabled, onChange }: { value: "store" | "disabled"; disabled: boolean; onChange: (value: "store" | "disabled") => void }) {
|
||||
function RawJsonSystemSelect({ value, disabled, onChange }: {value: "store" | "disabled";disabled: boolean;onChange: (value: "store" | "disabled") => void;}) {
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as "store" | "disabled")}>
|
||||
<option value="store">Store</option>
|
||||
<option value="disabled">Disable storage</option>
|
||||
</select>
|
||||
);
|
||||
<option value="store">i18n:govoplan-core.store.0d8a7046</option>
|
||||
<option value="disabled">i18n:govoplan-core.disable_storage.07c44d8b</option>
|
||||
</select>);
|
||||
|
||||
}
|
||||
|
||||
function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }: { value: RawJsonValue; parentStoresRawJson: boolean; disabled: boolean; onChange: (value: RawJsonValue) => void }) {
|
||||
function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }: {value: RawJsonValue;parentStoresRawJson: boolean;disabled: boolean;onChange: (value: RawJsonValue) => void;}) {
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as RawJsonValue)}>
|
||||
<option value="inherit">Inherit</option>
|
||||
<option value="keep" disabled={!parentStoresRawJson}>Store if parent allows</option>
|
||||
<option value="disable">Disable storage</option>
|
||||
</select>
|
||||
);
|
||||
<option value="inherit">i18n:govoplan-core.inherit.18f99833</option>
|
||||
<option value="keep" disabled={!parentStoresRawJson}>i18n:govoplan-core.store_if_parent_allows.36dcb07b</option>
|
||||
<option value="disable">i18n:govoplan-core.disable_storage.07c44d8b</option>
|
||||
</select>);
|
||||
|
||||
}
|
||||
|
||||
function RetentionDaysField({ value, disabled, placeholder, max, onChange }: { value?: number | null; disabled: boolean; placeholder: string; max?: number | null; onChange: (value: string) => void }) {
|
||||
function RetentionDaysField({ value, disabled, placeholder, max, onChange }: {value?: number | null;disabled: boolean;placeholder: string;max?: number | null;onChange: (value: string) => void;}) {
|
||||
function handleChange(nextValue: string) {
|
||||
const trimmed = nextValue.trim();
|
||||
if (trimmed === "" || max === null || max === undefined) {
|
||||
@@ -485,7 +516,7 @@ function RetentionDaysField({ value, disabled, placeholder, max, onChange }: { v
|
||||
return <input type="number" min={0} max={max ?? undefined} value={value ?? ""} disabled={disabled} placeholder={placeholder} onChange={(event) => handleChange(event.target.value)} />;
|
||||
}
|
||||
|
||||
function AuditDetailSelect({ value, includeInherit, parentValue, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; parentValue?: PrivacyRetentionPolicy["audit_detail_level"] | null; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
|
||||
function AuditDetailSelect({ value, includeInherit, parentValue, disabled, onChange }: {value: AuditDetailValue;includeInherit: boolean;parentValue?: PrivacyRetentionPolicy["audit_detail_level"] | null;disabled: boolean;onChange: (value: AuditDetailValue) => void;}) {
|
||||
const minimumOrder = parentValue ? auditDetailOrder[parentValue] : 0;
|
||||
function optionDisabled(option: PrivacyRetentionPolicy["audit_detail_level"]): boolean {
|
||||
return auditDetailOrder[option] < minimumOrder;
|
||||
@@ -493,12 +524,12 @@ function AuditDetailSelect({ value, includeInherit, parentValue, disabled, onCha
|
||||
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as AuditDetailValue)}>
|
||||
{includeInherit && <option value="inherit">Inherit</option>}
|
||||
<option value="full" disabled={optionDisabled("full")}>Full</option>
|
||||
<option value="redacted" disabled={optionDisabled("redacted")}>Redacted</option>
|
||||
<option value="minimal" disabled={optionDisabled("minimal")}>Minimal</option>
|
||||
</select>
|
||||
);
|
||||
{includeInherit && <option value="inherit">i18n:govoplan-core.inherit.18f99833</option>}
|
||||
<option value="full" disabled={optionDisabled("full")}>i18n:govoplan-core.full.10b28a8c</option>
|
||||
<option value="redacted" disabled={optionDisabled("redacted")}>i18n:govoplan-core.redacted.8780785a</option>
|
||||
<option value="minimal" disabled={optionDisabled("minimal")}>i18n:govoplan-core.minimal.a711cca9</option>
|
||||
</select>);
|
||||
|
||||
}
|
||||
|
||||
function rawJsonValue(value: boolean | undefined): RawJsonValue {
|
||||
@@ -511,13 +542,13 @@ function auditDetailValue(value: PrivacyRetentionPolicyPatch["audit_detail_level
|
||||
}
|
||||
|
||||
function daysLabel(value?: number | null): string {
|
||||
return value === null || value === undefined ? "Unlimited" : `${value} day${value === 1 ? "" : "s"}`;
|
||||
return value === null || value === undefined ? "i18n:govoplan-core.unlimited.b8bef37b" : `${value} day${value === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function auditDetailLabel(value: PrivacyRetentionPolicy["audit_detail_level"]): string {
|
||||
if (value === "full") return "Full";
|
||||
if (value === "redacted") return "Redacted";
|
||||
return "Minimal";
|
||||
if (value === "full") return "i18n:govoplan-core.full.10b28a8c";
|
||||
if (value === "redacted") return "i18n:govoplan-core.redacted.8780785a";
|
||||
return "i18n:govoplan-core.minimal.a711cca9";
|
||||
}
|
||||
|
||||
function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
|
||||
@@ -528,15 +559,15 @@ function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
|
||||
}
|
||||
const allowCount = Object.keys(policy.allow_lower_level_limits ?? {}).length;
|
||||
count += allowCount;
|
||||
return count === 0 ? "Fully inherited" : `${count} local override${count === 1 ? "" : "s"}`;
|
||||
return count === 0 ? "i18n:govoplan-core.fully_inherited.5e48132a" : `${count} local override${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function retentionPolicySourcePath(scopeType: PrivacyRetentionPolicyScope): string[] {
|
||||
if (scopeType === "system") return ["System"];
|
||||
if (scopeType === "tenant") return ["System", "Tenant"];
|
||||
if (scopeType === "user") return ["System", "Tenant", "User"];
|
||||
if (scopeType === "group") return ["System", "Tenant", "Group"];
|
||||
return ["System", "Tenant", "Owner policy", "Campaign"];
|
||||
if (scopeType === "system") return ["i18n:govoplan-core.system.bc0792d8"];
|
||||
if (scopeType === "tenant") return ["i18n:govoplan-core.system.bc0792d8", "i18n:govoplan-core.tenant.3ca93c78"];
|
||||
if (scopeType === "user") return ["i18n:govoplan-core.system.bc0792d8", "i18n:govoplan-core.tenant.3ca93c78", "i18n:govoplan-core.user.9f8a2389"];
|
||||
if (scopeType === "group") return ["i18n:govoplan-core.system.bc0792d8", "i18n:govoplan-core.tenant.3ca93c78", "i18n:govoplan-core.group.171a0606"];
|
||||
return ["i18n:govoplan-core.system.bc0792d8", "i18n:govoplan-core.tenant.3ca93c78", "i18n:govoplan-core.owner_policy.1e8df143", "i18n:govoplan-core.campaign.69390e16"];
|
||||
}
|
||||
|
||||
function normalizeAllowLimits(value: PrivacyRetentionLimitPermissionPatch | PrivacyRetentionLimitPermissions | null | undefined): PrivacyRetentionLimitPermissions {
|
||||
@@ -581,12 +612,12 @@ function normalizePatchForSave(policy: PrivacyRetentionPolicyPatch, parentPolicy
|
||||
delete allowPatch[field.key];
|
||||
}
|
||||
}
|
||||
if (Object.keys(allowPatch).length > 0) normalized.allow_lower_level_limits = allowPatch;
|
||||
else delete normalized.allow_lower_level_limits;
|
||||
if (Object.keys(allowPatch).length > 0) normalized.allow_lower_level_limits = allowPatch;else
|
||||
delete normalized.allow_lower_level_limits;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return "Request failed";
|
||||
return "i18n:govoplan-core.request_failed.9fcda32c";
|
||||
}
|
||||
|
||||
29
webui/src/features/privacy/policyLogic.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type PrivacyRetentionPolicyFieldKey =
|
||||
| "store_raw_campaign_json"
|
||||
| "raw_campaign_json_retention_days"
|
||||
| "generated_eml_retention_days"
|
||||
| "stored_report_detail_retention_days"
|
||||
| "mock_mailbox_retention_days"
|
||||
| "audit_detail_retention_days"
|
||||
| "audit_detail_level";
|
||||
|
||||
export type RetentionPolicyLimitCarrier = {
|
||||
allow_lower_level_limits?: Partial<Record<PrivacyRetentionPolicyFieldKey, boolean>> | null;
|
||||
};
|
||||
|
||||
export function privacyRetentionParentAllowsField(parentPolicy: RetentionPolicyLimitCarrier | null, key: PrivacyRetentionPolicyFieldKey): boolean {
|
||||
return !parentPolicy || parentPolicy.allow_lower_level_limits?.[key] !== false;
|
||||
}
|
||||
|
||||
export function privacyRetentionLocalAllowsLowerLevel(
|
||||
policy: RetentionPolicyLimitCarrier,
|
||||
parentPolicy: RetentionPolicyLimitCarrier | null,
|
||||
key: PrivacyRetentionPolicyFieldKey,
|
||||
isSystem: boolean
|
||||
): boolean {
|
||||
const localLimits = policy.allow_lower_level_limits ?? {};
|
||||
if (isSystem) return localLimits[key] !== false;
|
||||
const localValue = localLimits[key];
|
||||
if (localValue !== undefined) return localValue && privacyRetentionParentAllowsField(parentPolicy, key);
|
||||
return privacyRetentionParentAllowsField(parentPolicy, key);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo, MailProfilesUiCapability } from "../../types";
|
||||
import type { ApiSettings, AuthInfo, FilesConnectorsUiCapability, MailProfilesUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
@@ -11,30 +11,47 @@ import { apiFetch } from "../../api/client";
|
||||
import { updateProfile } from "../../api/auth";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type SettingsSection = "profile" | "mail-profiles" | "interface" | "workspace" | "local-connection" | "notifications";
|
||||
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | "notifications";
|
||||
|
||||
function settingsGroups(canUseMailProfiles: boolean): ModuleSubnavGroup<SettingsSection>[] {
|
||||
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
||||
compact_tables: false,
|
||||
show_inline_help_hints: true,
|
||||
reduce_motion: false,
|
||||
sticky_section_sidebars: true,
|
||||
theme: "system"
|
||||
};
|
||||
|
||||
const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
|
||||
{ value: "system", label: "i18n:govoplan-core.system_default.1f06f3ed" },
|
||||
{ value: "light", label: "i18n:govoplan-core.light_theme.7878f1fa" },
|
||||
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
||||
];
|
||||
|
||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean): ModuleSubnavGroup<SettingsSection>[] {
|
||||
return [
|
||||
{
|
||||
title: "ACCOUNT",
|
||||
items: [
|
||||
{ id: "profile", label: "My profile" },
|
||||
...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "Mail profiles" }] : [])
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "UI SETTINGS",
|
||||
items: [
|
||||
{ id: "interface", label: "Interface" },
|
||||
{ id: "workspace", label: "Workspace" },
|
||||
{ id: "local-connection", label: "Local connection" },
|
||||
{ id: "notifications", label: "Notifications" }
|
||||
]
|
||||
}
|
||||
];
|
||||
{
|
||||
title: "i18n:govoplan-core.account.f967543b",
|
||||
items: [
|
||||
{ id: "profile", label: "i18n:govoplan-core.my_profile.2f3df0a9" },
|
||||
...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "i18n:govoplan-core.mail_profiles.8a8018b7" }] : []),
|
||||
...(canUseFileConnectors ? [{ id: "file-connectors" as const, label: "i18n:govoplan-core.file_connections.1e362326" }] : [])]
|
||||
|
||||
},
|
||||
{
|
||||
title: "i18n:govoplan-core.ui_settings.9e9cc5ea",
|
||||
items: [
|
||||
{ id: "interface", label: "i18n:govoplan-core.interface.7b4db7ef" },
|
||||
{ id: "workspace", label: "i18n:govoplan-core.workspace.4ca0a75c" },
|
||||
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" },
|
||||
{ id: "notifications", label: "i18n:govoplan-core.notifications.753a22b2" }]
|
||||
|
||||
}];
|
||||
|
||||
}
|
||||
|
||||
export default function SettingsPage({
|
||||
@@ -42,49 +59,101 @@ export default function SettingsPage({
|
||||
auth,
|
||||
onSettingsChange,
|
||||
onAuthChange
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onSettingsChange: (settings: ApiSettings) => void;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles), [canUseMailProfiles]);
|
||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors), [canUseFileConnectors, canUseMailProfiles]);
|
||||
const requestedSection = searchParams.get("section") as SettingsSection | null;
|
||||
const [active, setActive] = useState<SettingsSection>(settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface");
|
||||
const active: SettingsSection = settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface";
|
||||
const currentUiPreferences = normalizeUiPreferences(auth.user.ui_preferences);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState("");
|
||||
const [compactTables, setCompactTables] = useState(false);
|
||||
const [showHelpHints, setShowHelpHints] = useState(true);
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
const [stickySections, setStickySections] = useState(true);
|
||||
const [testResultTone, setTestResultTone] = useState<"success" | "warning">("success");
|
||||
const [compactTables, setCompactTables] = useState(currentUiPreferences.compact_tables);
|
||||
const [showHelpHints, setShowHelpHints] = useState(currentUiPreferences.show_inline_help_hints);
|
||||
const [reduceMotion, setReduceMotion] = useState(currentUiPreferences.reduce_motion);
|
||||
const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars);
|
||||
const [theme, setTheme] = useState<UserUiTheme>(currentUiPreferences.theme);
|
||||
const [uiBusy, setUiBusy] = useState(false);
|
||||
const [uiResult, setUiResult] = useState("");
|
||||
const [uiResultTone, setUiResultTone] = useState<"success" | "warning">("success");
|
||||
const [profileName, setProfileName] = useState(auth.user.display_name || "");
|
||||
const [tenantProfileName, setTenantProfileName] = useState(auth.user.tenant_display_name || "");
|
||||
const [profileBusy, setProfileBusy] = useState(false);
|
||||
const [profileResult, setProfileResult] = useState("");
|
||||
const [profileResultTone, setProfileResultTone] = useState<"success" | "warning">("success");
|
||||
const profileDirty = profileName !== (auth.user.display_name || "") || tenantProfileName !== (auth.user.tenant_display_name || "");
|
||||
const uiDirty =
|
||||
compactTables !== currentUiPreferences.compact_tables ||
|
||||
showHelpHints !== currentUiPreferences.show_inline_help_hints ||
|
||||
reduceMotion !== currentUiPreferences.reduce_motion ||
|
||||
stickySections !== currentUiPreferences.sticky_section_sidebars ||
|
||||
theme !== currentUiPreferences.theme;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: active === "profile" && profileDirty,
|
||||
onSave: saveProfile,
|
||||
onDiscard: () => {
|
||||
setProfileName(auth.user.display_name || "");
|
||||
setTenantProfileName(auth.user.tenant_display_name || "");
|
||||
}
|
||||
});
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: (active === "interface" || active === "workspace") && uiDirty,
|
||||
onSave: saveUiPreferences,
|
||||
onDiscard: resetUiPreferences
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsSectionAvailable(settingsSubnav, requestedSection)) {
|
||||
setActive(requestedSection);
|
||||
} else if (!settingsSectionAvailable(settingsSubnav, active)) {
|
||||
setActive("interface");
|
||||
if (requestedSection && !settingsSectionAvailable(settingsSubnav, requestedSection)) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("section", "interface");
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
}, [active, requestedSection, settingsSubnav]);
|
||||
}, [requestedSection, searchParams, setSearchParams, settingsSubnav]);
|
||||
|
||||
useEffect(() => {
|
||||
setProfileName(auth.user.display_name || "");
|
||||
setTenantProfileName(auth.user.tenant_display_name || "");
|
||||
}, [auth.user.display_name, auth.user.tenant_display_name]);
|
||||
|
||||
useEffect(() => {
|
||||
setCompactTables(currentUiPreferences.compact_tables);
|
||||
setShowHelpHints(currentUiPreferences.show_inline_help_hints);
|
||||
setReduceMotion(currentUiPreferences.reduce_motion);
|
||||
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
||||
setTheme(currentUiPreferences.theme);
|
||||
}, [
|
||||
currentUiPreferences.compact_tables,
|
||||
currentUiPreferences.show_inline_help_hints,
|
||||
currentUiPreferences.reduce_motion,
|
||||
currentUiPreferences.sticky_section_sidebars,
|
||||
currentUiPreferences.theme
|
||||
]);
|
||||
|
||||
function selectSection(section: SettingsSection) {
|
||||
setActive(section);
|
||||
setSearchParams(section === "interface" ? {} : { section });
|
||||
if (section === active) return;
|
||||
requestNavigation(() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("section", section);
|
||||
setSearchParams(next);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
async function saveProfile(): Promise<boolean> {
|
||||
setProfileBusy(true);
|
||||
setProfileResult("");
|
||||
try {
|
||||
@@ -93,21 +162,63 @@ export default function SettingsPage({
|
||||
tenant_display_name: tenantProfileName.trim() || null
|
||||
});
|
||||
onAuthChange(next);
|
||||
setProfileResult("Profile saved. The account menu has been updated.");
|
||||
setProfileResultTone("success");
|
||||
setProfileResult("i18n:govoplan-core.profile_saved_the_account_menu_has_been_updated.aee56076");
|
||||
return true;
|
||||
} catch (err) {
|
||||
setProfileResultTone("warning");
|
||||
setProfileResult(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setProfileBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetUiPreferences() {
|
||||
setCompactTables(currentUiPreferences.compact_tables);
|
||||
setShowHelpHints(currentUiPreferences.show_inline_help_hints);
|
||||
setReduceMotion(currentUiPreferences.reduce_motion);
|
||||
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
||||
setTheme(currentUiPreferences.theme);
|
||||
}
|
||||
|
||||
function uiPreferencePayload(): UserUiPreferences {
|
||||
return {
|
||||
compact_tables: compactTables,
|
||||
show_inline_help_hints: showHelpHints,
|
||||
reduce_motion: reduceMotion,
|
||||
sticky_section_sidebars: stickySections,
|
||||
theme
|
||||
};
|
||||
}
|
||||
|
||||
async function saveUiPreferences(): Promise<boolean> {
|
||||
setUiBusy(true);
|
||||
setUiResult("");
|
||||
try {
|
||||
const next = await updateProfile(settings, { ui_preferences: uiPreferencePayload() });
|
||||
onAuthChange(next);
|
||||
setUiResultTone("success");
|
||||
setUiResult("i18n:govoplan-core.preferences_saved.c8cd3501");
|
||||
return true;
|
||||
} catch (err) {
|
||||
setUiResultTone("warning");
|
||||
setUiResult(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setUiBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
setTesting(true);
|
||||
setTestResult("");
|
||||
try {
|
||||
await apiFetch<unknown>(settings, "/health");
|
||||
setTestResult("Connection successful. The backend health endpoint responded.");
|
||||
setTestResultTone("success");
|
||||
setTestResult("i18n:govoplan-core.connection_successful_the_backend_health_endpoin.14eb9295");
|
||||
} catch (err) {
|
||||
setTestResultTone("warning");
|
||||
setTestResult(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
@@ -121,177 +232,228 @@ export default function SettingsPage({
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Settings</PageTitle>
|
||||
<p>Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.</p>
|
||||
<PageTitle>i18n:govoplan-core.settings.c7f73bb5</PageTitle>
|
||||
<p>i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{active === "profile" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="My profile">
|
||||
{active === "profile" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.my_profile.2f3df0a9">
|
||||
<div className="form-grid">
|
||||
<FormField label="Account display name" help="This global name is shown in the title bar and follows you across tenant memberships.">
|
||||
<FormField label="i18n:govoplan-core.account_display_name.03ed8fc5" help="i18n:govoplan-core.this_global_name_is_shown_in_the_title_bar_and_f.1d46240e">
|
||||
<input value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder={auth.user.email} />
|
||||
</FormField>
|
||||
<FormField label="Tenant display name" help="Optional local alias for the active tenant. It does not replace the global account name in the title bar.">
|
||||
<input value={tenantProfileName} onChange={(event) => setTenantProfileName(event.target.value)} placeholder="Use account display name" />
|
||||
<FormField label="i18n:govoplan-core.tenant_display_name.9ae4495e" help="i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14">
|
||||
<input value={tenantProfileName} onChange={(event) => setTenantProfileName(event.target.value)} placeholder="i18n:govoplan-core.use_account_display_name.570926b0" />
|
||||
</FormField>
|
||||
<FormField label="Email">
|
||||
<FormField label="i18n:govoplan-core.email.84add5b2">
|
||||
<input value={auth.user.email} disabled />
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => void saveProfile()} disabled={profileBusy}>{profileBusy ? "Saving…" : "Save profile"}</Button>
|
||||
<Button variant="primary" onClick={() => void saveProfile()} disabled={profileBusy}>{profileBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_profile.f597c0e8"}</Button>
|
||||
</div>
|
||||
{profileResult && <DismissibleAlert tone={profileResult.startsWith("Profile saved") ? "success" : "warning"} resetKey={profileResult} floating>{profileResult}</DismissibleAlert>}
|
||||
{profileResult && <DismissibleAlert tone={profileResultTone} resetKey={profileResult} floating>{profileResult}</DismissibleAlert>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Account context">
|
||||
<Card title="i18n:govoplan-core.account_context.5bb0a918">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Account ID</dt><dd>{auth.user.account_id}</dd></div>
|
||||
<div><dt>Active tenant</dt><dd>{auth.active_tenant?.name || auth.tenant.name}</dd></div>
|
||||
<div><dt>Tenant roles</dt><dd>{auth.roles.filter((role) => role.level !== "system").map((role) => role.name).join(", ") || "None"}</dd></div>
|
||||
<div><dt>System roles</dt><dd>{auth.roles.filter((role) => role.level === "system").map((role) => role.name).join(", ") || "None"}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.account_id.c5e0db28</dt><dd>{auth.user.account_id}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.active_tenant.39b16ee9</dt><dd>{auth.active_tenant?.name || auth.tenant.name}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.tenant_roles.51aca82d</dt><dd>{auth.roles.filter((role) => role.level !== "system").map((role) => role.name).join(", ") || "i18n:govoplan-core.none.6eef6648"}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.system_roles.a9461aa6</dt><dd>{auth.roles.filter((role) => role.level === "system").map((role) => role.name).join(", ") || "i18n:govoplan-core.none.6eef6648"}</dd></div>
|
||||
</dl>
|
||||
<p className="muted small-note">Email changes and password management require dedicated identity-verification workflows and are intentionally not part of this first self-service profile editor.</p>
|
||||
<p className="muted small-note">i18n:govoplan-core.email_changes_and_password_management_require_de.f7443b69</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
{active === "mail-profiles" && MailProfileScopeManager && (
|
||||
<MailProfileScopeManager
|
||||
settings={settings}
|
||||
scopeType="user"
|
||||
scopeId={auth.user.id}
|
||||
profileTitle="My mail server profiles"
|
||||
policyTitle="My mail profile policy"
|
||||
canWriteProfiles={hasScope(auth, "mail_servers:write")}
|
||||
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
|
||||
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])}
|
||||
/>
|
||||
)}
|
||||
{active === "mail-profiles" && MailProfileScopeManager &&
|
||||
<MailProfileScopeManager
|
||||
settings={settings}
|
||||
scopeType="user"
|
||||
scopeId={auth.user.id}
|
||||
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
|
||||
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
|
||||
canWriteProfiles={hasScope(auth, "mail_servers:write")}
|
||||
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
|
||||
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
|
||||
|
||||
{active === "interface" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Interface preferences">
|
||||
}
|
||||
|
||||
{active === "file-connectors" && FileConnectorScopeManager &&
|
||||
<FileConnectorScopeManager
|
||||
settings={settings}
|
||||
scopeType="user"
|
||||
scopeId={auth.user.id}
|
||||
title="i18n:govoplan-core.my_file_connections.00252f2a"
|
||||
canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />
|
||||
|
||||
}
|
||||
|
||||
{active === "interface" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.interface_preferences.b82b39a7">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Compact tables"
|
||||
help="Prepared UI preference for denser tables. The current table layout remains unchanged until this is wired globally."
|
||||
checked={compactTables}
|
||||
onChange={setCompactTables}
|
||||
/>
|
||||
label="i18n:govoplan-core.compact_tables.c755d9ee"
|
||||
help="i18n:govoplan-core.prepared_ui_preference_for_denser_tables_the_cur.45698d83"
|
||||
checked={compactTables}
|
||||
onChange={setCompactTables} />
|
||||
|
||||
<ToggleSwitch
|
||||
label="Show inline help hints"
|
||||
help="Controls contextual UI help markers once persisted user preferences are available."
|
||||
checked={showHelpHints}
|
||||
onChange={setShowHelpHints}
|
||||
/>
|
||||
label="i18n:govoplan-core.show_inline_help_hints.47cf5aaa"
|
||||
help="i18n:govoplan-core.controls_contextual_ui_help_markers_once_persist.0eaef9c4"
|
||||
checked={showHelpHints}
|
||||
onChange={setShowHelpHints} />
|
||||
|
||||
<ToggleSwitch
|
||||
label="Reduce motion"
|
||||
help="Prepared preference for users who prefer fewer animations."
|
||||
checked={reduceMotion}
|
||||
onChange={setReduceMotion}
|
||||
/>
|
||||
label="i18n:govoplan-core.reduce_motion.25a5aef5"
|
||||
help="i18n:govoplan-core.prepared_preference_for_users_who_prefer_fewer_a.b288e8ab"
|
||||
checked={reduceMotion}
|
||||
onChange={setReduceMotion} />
|
||||
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => void saveUiPreferences()} disabled={uiBusy || !uiDirty}>{uiBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_preferences.0f1a7e44"}</Button>
|
||||
</div>
|
||||
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Theme and language">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Theme</dt><dd>System default for now</dd></div>
|
||||
<div><dt>Accent color</dt><dd>Default brand accent</dd></div>
|
||||
<div><dt>Language</dt><dd>Browser/application default</dd></div>
|
||||
<div><dt>Density</dt><dd>{compactTables ? "Compact preview" : "Comfortable"}</dd></div>
|
||||
</dl>
|
||||
<Card title="i18n:govoplan-core.theme_and_language.60a8cf59">
|
||||
<div className="form-grid">
|
||||
<FormField label="i18n:govoplan-core.language.89b86ab0">
|
||||
<select value={language} onChange={(event) => setLanguage(event.target.value)} disabled={selectableLanguages.length <= 1}>
|
||||
{selectableLanguages.map((item) =>
|
||||
<option key={item.code} value={item.code}>{item.code.toUpperCase()} - {item.nativeLabel || item.label}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-core.theme.a797e309">
|
||||
<select value={theme} onChange={(event) => setTheme(event.target.value as UserUiTheme)}>
|
||||
{UI_THEME_OPTIONS.map((item) =>
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div>
|
||||
<div><dt>i18n:govoplan-core.language.89b86ab0</dt><dd>{languageLabel}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.enabled.df174a3f</dt><dd>{enabledLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.available.7c62a142</dt><dd>{availableLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.density.f9160c22</dt><dd>{compactTables ? "i18n:govoplan-core.compact_preview.3e06901d" : "i18n:govoplan-core.comfortable.2313707a"}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
{active === "workspace" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Campaign workspace">
|
||||
{active === "workspace" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.campaign_workspace.c345580f">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Sticky section sidebars"
|
||||
help="Keeps campaign and admin section navigation in view while scrolling."
|
||||
checked={stickySections}
|
||||
onChange={setStickySections}
|
||||
/>
|
||||
label="i18n:govoplan-core.sticky_section_sidebars.399c92d4"
|
||||
help="i18n:govoplan-core.keeps_campaign_and_admin_section_navigation_in_v.f3602938"
|
||||
checked={stickySections}
|
||||
onChange={setStickySections} />
|
||||
|
||||
<ToggleSwitch
|
||||
label="Keep page shell visible while loading"
|
||||
help="The current UI already keeps the section shell visible and overlays loading indicators during refresh."
|
||||
checked
|
||||
disabled
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
label="i18n:govoplan-core.keep_page_shell_visible_while_loading.17fc142a"
|
||||
help="i18n:govoplan-core.the_current_ui_already_keeps_the_section_shell_v.c9bbc227"
|
||||
checked
|
||||
disabled
|
||||
onChange={() => undefined} />
|
||||
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => void saveUiPreferences()} disabled={uiBusy || !uiDirty}>{uiBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_preferences.0f1a7e44"}</Button>
|
||||
</div>
|
||||
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Editor behavior">
|
||||
<Card title="i18n:govoplan-core.editor_behavior.99581bc1">
|
||||
<div className="placeholder-stack">
|
||||
<span>Manual save with unsaved-change guard</span>
|
||||
<span>Readable chooser fields for file/path selection</span>
|
||||
<span>Field-type-aware recipient data inputs</span>
|
||||
<span>Template placeholder chips and preview overlays</span>
|
||||
<span>i18n:govoplan-core.manual_save_with_unsaved_change_guard.ef42e803</span>
|
||||
<span>i18n:govoplan-core.readable_chooser_fields_for_file_path_selection.d3aee1a7</span>
|
||||
<span>i18n:govoplan-core.field_type_aware_recipient_data_inputs.2892f175</span>
|
||||
<span>i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
{active === "local-connection" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Local API connection">
|
||||
{active === "local-connection" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.local_api_connection.bfb02921">
|
||||
<div className="form-grid">
|
||||
<FormField label="API base URL" help="Leave empty to use the same origin. In Vite dev, /api is proxied to the FastAPI backend.">
|
||||
<FormField label="i18n:govoplan-core.api_base_url.1358fba4" help="i18n:govoplan-core.leave_empty_to_use_the_same_origin_in_vite_dev_a.9a1c25d7">
|
||||
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
|
||||
</FormField>
|
||||
<FormField label="Automation API key" help="Used only when there is no browser session token. Browser login remains the preferred interactive mode.">
|
||||
<FormField label="i18n:govoplan-core.automation_api_key.5d4e2e6e" help="i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70">
|
||||
<PasswordField
|
||||
value={settings.apiKey}
|
||||
autoComplete="off"
|
||||
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })}
|
||||
/>
|
||||
value={settings.apiKey}
|
||||
autoComplete="off"
|
||||
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })} />
|
||||
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>
|
||||
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "i18n:govoplan-core.testing.95c4564a" : "i18n:govoplan-core.test_connection.ccf66f07"}</Button>
|
||||
</div>
|
||||
{testResult && <DismissibleAlert tone={testResult.startsWith("Connection successful") ? "success" : "warning"} resetKey={testResult} floating>{testResult}</DismissibleAlert>}
|
||||
{testResult && <DismissibleAlert tone={testResultTone} resetKey={testResult} floating>{testResult}</DismissibleAlert>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Session state">
|
||||
<Card title="i18n:govoplan-core.session_state.b7a3d0f4">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Browser session</dt><dd>Active via HttpOnly cookie</dd></div>
|
||||
<div><dt>Automation key</dt><dd>{settings.apiKey ? "Configured" : "Not configured"}</dd></div>
|
||||
<div><dt>Backend mode</dt><dd>{settings.apiBaseUrl ? "Explicit API URL" : "Same-origin / proxied"}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.browser_session.cc021a31</dt><dd>i18n:govoplan-core.active_via_httponly_cookie.9d05e6ba</dd></div>
|
||||
<div><dt>i18n:govoplan-core.automation_key.78f1ddbc</dt><dd>{settings.apiKey ? "i18n:govoplan-core.configured.668c5fff" : "i18n:govoplan-core.not_configured.811931bb"}</dd></div>
|
||||
<div><dt>i18n:govoplan-core.backend_mode.c863723a</dt><dd>{settings.apiBaseUrl ? "i18n:govoplan-core.explicit_api_url.b117b4b8" : "i18n:govoplan-core.same_origin_proxied.c39e6e2b"}</dd></div>
|
||||
</dl>
|
||||
<p className="muted small-note">Tenant, user, mail-server and policy administration has moved to Admin. This page keeps browser-local configuration.</p>
|
||||
<p className="muted small-note">i18n:govoplan-core.tenant_user_mail_server_and_policy_administratio.2f551271</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
{active === "notifications" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Notification preferences">
|
||||
<p className="muted">Prepared for later personal notification preferences. Backend and browser notification wiring are not active yet.</p>
|
||||
{active === "notifications" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.notification_preferences.0ead6c12">
|
||||
<p className="muted">i18n:govoplan-core.prepared_for_later_personal_notification_prefere.3fe73f86</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>In-app completion notices</span>
|
||||
<span>Email summary preferences</span>
|
||||
<span>Failure and warning alerts</span>
|
||||
<span>i18n:govoplan-core.in_app_completion_notices.b68f2f4c</span>
|
||||
<span>i18n:govoplan-core.email_summary_preferences.b6c1dfb1</span>
|
||||
<span>i18n:govoplan-core.failure_and_warning_alerts.939d21c2</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Quiet UI mode">
|
||||
<Card title="i18n:govoplan-core.quiet_ui_mode.1b0bd558">
|
||||
<div className="placeholder-stack">
|
||||
<span>Mute non-critical banners</span>
|
||||
<span>Batch repetitive notices</span>
|
||||
<span>Keep validation and send warnings visible</span>
|
||||
<span>i18n:govoplan-core.mute_non_critical_banners.27b23a4a</span>
|
||||
<span>i18n:govoplan-core.batch_repetitive_notices.cc893559</span>
|
||||
<span>i18n:govoplan-core.keep_validation_and_send_warnings_visible.9d6e0cf4</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
|
||||
function settingsSectionAvailable(groups: ModuleSubnavGroup<SettingsSection>[], section: SettingsSection | null | undefined): section is SettingsSection {
|
||||
return Boolean(section && groups.some((group) => group.items.some((item) => "id" in item && item.id === section)));
|
||||
}
|
||||
|
||||
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
|
||||
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
|
||||
return {
|
||||
compact_tables: Boolean(value?.compact_tables ?? DEFAULT_UI_PREFERENCES.compact_tables),
|
||||
show_inline_help_hints: Boolean(value?.show_inline_help_hints ?? DEFAULT_UI_PREFERENCES.show_inline_help_hints),
|
||||
reduce_motion: Boolean(value?.reduce_motion ?? DEFAULT_UI_PREFERENCES.reduce_motion),
|
||||
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
|
||||
theme
|
||||
};
|
||||
}
|
||||
|
||||
function themeLabel(value: UserUiTheme): string {
|
||||
return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label;
|
||||
}
|
||||
|
||||
382
webui/src/i18n/LanguageContext.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
|
||||
import type { PlatformTranslationDictionary, PlatformTranslations } from "../types";
|
||||
import { generatedTranslations } from "./generatedTranslations";
|
||||
|
||||
export type PlatformLanguage = {
|
||||
code: string;
|
||||
label: string;
|
||||
nativeLabel?: string;
|
||||
};
|
||||
|
||||
export type PlatformLanguageContextValue = {
|
||||
availableLanguages: PlatformLanguage[];
|
||||
enabledLanguages: PlatformLanguage[];
|
||||
selectableLanguages: PlatformLanguage[];
|
||||
language: string;
|
||||
languageLabel: string;
|
||||
setLanguage: (code: string) => void;
|
||||
t: (key: string, fallback?: string) => string;
|
||||
translateText: (value: string) => string;
|
||||
};
|
||||
|
||||
type PlatformLanguageProviderProps = {
|
||||
children: ReactNode;
|
||||
systemAvailableLanguages?: PlatformLanguage[] | null;
|
||||
systemEnabledLanguageCodes?: string[] | null;
|
||||
userEnabledLanguageCodes?: string[] | null;
|
||||
defaultLanguage?: string | null;
|
||||
preferredLanguageCode?: string | null;
|
||||
onLanguageChange?: (code: string) => void;
|
||||
moduleTranslations?: Array<PlatformTranslations | null | undefined>;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "govoplan.language";
|
||||
const PRIMARY_LANGUAGE = "en";
|
||||
const I18N_MESSAGE_SEPARATOR = "::";
|
||||
const TRANSLATABLE_ATTRIBUTES = ["aria-label", "alt", "placeholder", "title"];
|
||||
const SKIP_TRANSLATION_SELECTOR = "script, style, code, pre, textarea, [data-i18n-skip]";
|
||||
const textNodeOriginals = new WeakMap<Text, string>();
|
||||
|
||||
export const DEFAULT_AVAILABLE_LANGUAGES: PlatformLanguage[] = [
|
||||
{ code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" },
|
||||
{ code: "de", label: "i18n:govoplan-core.german.da91388c", nativeLabel: "i18n:govoplan-core.language_native_german" }];
|
||||
|
||||
|
||||
export const DEFAULT_TRANSLATIONS: PlatformTranslations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
};
|
||||
|
||||
const PlatformLanguageContext = createContext<PlatformLanguageContextValue | null>(null);
|
||||
|
||||
export function PlatformLanguageProvider({
|
||||
children,
|
||||
systemAvailableLanguages,
|
||||
systemEnabledLanguageCodes,
|
||||
userEnabledLanguageCodes,
|
||||
defaultLanguage,
|
||||
preferredLanguageCode,
|
||||
onLanguageChange,
|
||||
moduleTranslations = []
|
||||
}: PlatformLanguageProviderProps) {
|
||||
const availableLanguages = useMemo(() => normalizeLanguages(systemAvailableLanguages), [systemAvailableLanguages]);
|
||||
const enabledLanguages = useMemo(
|
||||
() => languagesForCodes(availableLanguages, systemEnabledLanguageCodes),
|
||||
[availableLanguages, systemEnabledLanguageCodes]
|
||||
);
|
||||
const selectableLanguages = useMemo(() => {
|
||||
const selected = userEnabledLanguageCodes?.length ? languagesForCodes(enabledLanguages, userEnabledLanguageCodes) : enabledLanguages;
|
||||
return selected.length ? selected : enabledLanguages;
|
||||
}, [enabledLanguages, userEnabledLanguageCodes]);
|
||||
const fallbackLanguage = preferredLanguage(selectableLanguages, defaultLanguage);
|
||||
const [language, setLanguageState] = useState(() => preferredLanguage(selectableLanguages, preferredLanguageCode ?? storedLanguage() ?? defaultLanguage));
|
||||
|
||||
useEffect(() => {
|
||||
if (selectableLanguages.some((item) => item.code === language)) return;
|
||||
setLanguageState(fallbackLanguage);
|
||||
}, [fallbackLanguage, language, selectableLanguages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferredLanguageCode) return;
|
||||
const preferred = preferredLanguage(selectableLanguages, preferredLanguageCode);
|
||||
if (preferred) setLanguageState((current) => current === preferred ? current : preferred);
|
||||
}, [preferredLanguageCode, selectableLanguages]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
localStorage.setItem(STORAGE_KEY, language);
|
||||
}, [language]);
|
||||
|
||||
const translations = useMemo(() => mergeTranslations(DEFAULT_TRANSLATIONS, moduleTranslations), [moduleTranslations]);
|
||||
const dictionary = useMemo(
|
||||
() => ({ ...(translations[PRIMARY_LANGUAGE] ?? {}), ...(translations[language] ?? {}) }),
|
||||
[language, translations]
|
||||
);
|
||||
const translateText = useCallback((value: string) => translatePlainText(value, language, dictionary), [dictionary, language]);
|
||||
const t = useCallback((key: string, fallback = key) => {
|
||||
if (normalizeLanguageCode(language) === PRIMARY_LANGUAGE) return fallback;
|
||||
return dictionary[key] ?? translatePlainText(fallback, language, dictionary);
|
||||
}, [dictionary, language]);
|
||||
|
||||
useEffect(() => {
|
||||
return installDomTranslationBridge(language, dictionary);
|
||||
}, [dictionary, language]);
|
||||
|
||||
function setLanguage(code: string) {
|
||||
const normalized = normalizeLanguageCode(code);
|
||||
if (!selectableLanguages.some((item) => item.code === normalized)) return;
|
||||
setLanguageState(normalized);
|
||||
onLanguageChange?.(normalized);
|
||||
}
|
||||
|
||||
const languageLabel = selectableLanguages.find((item) => item.code === language)?.label ?? language.toUpperCase();
|
||||
const contextValue = useMemo(
|
||||
() => ({ availableLanguages, enabledLanguages, selectableLanguages, language, languageLabel, setLanguage, t, translateText }),
|
||||
[availableLanguages, enabledLanguages, language, languageLabel, selectableLanguages, t, translateText]
|
||||
);
|
||||
|
||||
return (
|
||||
<PlatformLanguageContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</PlatformLanguageContext.Provider>);
|
||||
|
||||
}
|
||||
|
||||
export function usePlatformLanguage(): PlatformLanguageContextValue {
|
||||
const value = useContext(PlatformLanguageContext);
|
||||
if (value) return value;
|
||||
const language = preferredLanguage(DEFAULT_AVAILABLE_LANGUAGES, null);
|
||||
return {
|
||||
availableLanguages: DEFAULT_AVAILABLE_LANGUAGES,
|
||||
enabledLanguages: DEFAULT_AVAILABLE_LANGUAGES,
|
||||
selectableLanguages: DEFAULT_AVAILABLE_LANGUAGES,
|
||||
language,
|
||||
languageLabel: DEFAULT_AVAILABLE_LANGUAGES.find((item) => item.code === language)?.label ?? language.toUpperCase(),
|
||||
setLanguage: () => undefined,
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
translateText: (value: string) => value
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLanguageCode(value?: string | null): string {
|
||||
const normalized = String(value || "").trim().toLowerCase().replace(/_/g, "-");
|
||||
const parts = normalized.split(/[^a-z0-9]+/).filter(Boolean);
|
||||
return parts.join("-") || "en";
|
||||
}
|
||||
|
||||
export function i18nMessage(key: string, values?: Record<string, unknown>): string {
|
||||
if (!values || Object.keys(values).length === 0) return key;
|
||||
return `${key}${I18N_MESSAGE_SEPARATOR}${encodeURIComponent(JSON.stringify(values))}`;
|
||||
}
|
||||
|
||||
export function translateReactNode(node: ReactNode, translateText: (value: string) => string): ReactNode {
|
||||
if (typeof node === "string") return translateText(node);
|
||||
if (node === null || node === undefined || typeof node === "boolean") return node;
|
||||
if (Array.isArray(node)) return Children.map(node, (child) => translateReactNode(child, translateText));
|
||||
if (isValidElement(node)) {
|
||||
const props = node.props as { children?: ReactNode };
|
||||
if (props.children === undefined) return node;
|
||||
return cloneElement(
|
||||
node as ReactElement<{ children?: ReactNode }>,
|
||||
undefined,
|
||||
translateReactNode(props.children, translateText)
|
||||
);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function normalizeLanguages(languages?: PlatformLanguage[] | null): PlatformLanguage[] {
|
||||
const source = languages?.length ? languages : DEFAULT_AVAILABLE_LANGUAGES;
|
||||
const seen = new Set<string>();
|
||||
return source.flatMap((language) => {
|
||||
const code = normalizeLanguageCode(language.code);
|
||||
if (seen.has(code)) return [];
|
||||
seen.add(code);
|
||||
return [{ ...language, code }];
|
||||
});
|
||||
}
|
||||
|
||||
function languagesForCodes(languages: PlatformLanguage[], codes?: string[] | null): PlatformLanguage[] {
|
||||
if (!codes?.length) return languages;
|
||||
const enabled = new Set(codes.map(normalizeLanguageCode));
|
||||
const enabledPrimary = new Set(Array.from(enabled).map(primaryLanguageCode));
|
||||
const filtered = languages.filter((language) => enabled.has(language.code) || enabledPrimary.has(primaryLanguageCode(language.code)));
|
||||
return filtered.length ? filtered : languages;
|
||||
}
|
||||
|
||||
function preferredLanguage(languages: PlatformLanguage[], preferred?: string | null): string {
|
||||
const codes = new Set(languages.map((item) => item.code));
|
||||
const requested = normalizeLanguageCode(preferred ?? storedLanguage() ?? browserLanguage());
|
||||
if (codes.has(requested)) return requested;
|
||||
const primary = primaryLanguageCode(requested);
|
||||
const primaryMatch = languages.find((item) => primaryLanguageCode(item.code) === primary);
|
||||
if (primaryMatch) return primaryMatch.code;
|
||||
if (codes.has(PRIMARY_LANGUAGE)) return PRIMARY_LANGUAGE;
|
||||
return languages[0]?.code ?? PRIMARY_LANGUAGE;
|
||||
}
|
||||
|
||||
function primaryLanguageCode(value: string): string {
|
||||
return normalizeLanguageCode(value).split("-", 1)[0];
|
||||
}
|
||||
|
||||
function storedLanguage(): string | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
function browserLanguage(): string | null {
|
||||
if (typeof navigator === "undefined") return null;
|
||||
return navigator.language;
|
||||
}
|
||||
|
||||
function mergeTranslations(base: PlatformTranslations, catalogs: Array<PlatformTranslations | null | undefined>): PlatformTranslations {
|
||||
const merged: PlatformTranslations = {};
|
||||
for (const source of [base, ...catalogs]) {
|
||||
if (!source) continue;
|
||||
for (const [language, dictionary] of Object.entries(source)) {
|
||||
const code = normalizeLanguageCode(language);
|
||||
merged[code] = { ...(merged[code] ?? {}), ...dictionary };
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function translatePlainText(value: string, language: string, dictionary: PlatformTranslationDictionary): string {
|
||||
const match = value.match(/^(\s*)(.*?)(\s*)$/s);
|
||||
if (!match) return value;
|
||||
const [, prefix, body, suffix] = match;
|
||||
if (!body) return value;
|
||||
if (body.startsWith("i18n:")) {
|
||||
const { key, values } = parseI18nMessage(body);
|
||||
if (Object.prototype.hasOwnProperty.call(dictionary, key)) {
|
||||
const translated = formatI18nMessage(dictionary[key] ?? key, values, dictionary);
|
||||
return `${prefix}${translated}${suffix}`;
|
||||
}
|
||||
}
|
||||
if (body.includes("i18n:")) {
|
||||
return `${prefix}${translateEmbeddedI18nMessages(body, dictionary)}${suffix}`;
|
||||
}
|
||||
if (normalizeLanguageCode(language) === PRIMARY_LANGUAGE) return value;
|
||||
const translated = dictionary[body] ?? body;
|
||||
return `${prefix}${translated}${suffix}`;
|
||||
}
|
||||
|
||||
function translateEmbeddedI18nMessages(value: string, dictionary: PlatformTranslationDictionary): string {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
const keys = Object.keys(dictionary).filter((key) => key.startsWith("i18n:")).sort((left, right) => right.length - left.length);
|
||||
|
||||
while (index < value.length) {
|
||||
const tokenStart = value.indexOf("i18n:", index);
|
||||
if (tokenStart < 0) {
|
||||
output += value.slice(index);
|
||||
break;
|
||||
}
|
||||
output += value.slice(index, tokenStart);
|
||||
const key = keys.find((candidate) => value.startsWith(candidate, tokenStart));
|
||||
if (!key) {
|
||||
output += "i18n:";
|
||||
index = tokenStart + "i18n:".length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let token = key;
|
||||
let nextIndex = tokenStart + key.length;
|
||||
if (value.startsWith(I18N_MESSAGE_SEPARATOR, nextIndex)) {
|
||||
const encodedStart = nextIndex + I18N_MESSAGE_SEPARATOR.length;
|
||||
let encodedEnd = encodedStart;
|
||||
while (encodedEnd < value.length && !/[\s<>"'`]/.test(value[encodedEnd])) encodedEnd += 1;
|
||||
token = value.slice(tokenStart, encodedEnd);
|
||||
nextIndex = encodedEnd;
|
||||
}
|
||||
const { values } = parseI18nMessage(token);
|
||||
output += formatI18nMessage(dictionary[key] ?? key, values, dictionary);
|
||||
index = nextIndex;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseI18nMessage(value: string): { key: string; values: Record<string, unknown> } {
|
||||
const [key, encodedValues] = value.split(I18N_MESSAGE_SEPARATOR, 2);
|
||||
if (!encodedValues) return { key, values: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(encodedValues));
|
||||
return { key, values: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {} };
|
||||
} catch {
|
||||
return { key, values: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function formatI18nMessage(template: string, values: Record<string, unknown>, dictionary: PlatformTranslationDictionary): string {
|
||||
return template.replace(/\{([A-Za-z0-9_]+)\}/g, (_match, name) => {
|
||||
const value = values[name];
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string" && value.startsWith("i18n:")) {
|
||||
const nested = parseI18nMessage(value);
|
||||
return formatI18nMessage(dictionary[nested.key] ?? nested.key, nested.values, dictionary);
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
function installDomTranslationBridge(language: string, dictionary: PlatformTranslationDictionary): () => void {
|
||||
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => undefined;
|
||||
const root = document.body ?? document.getElementById("root");
|
||||
if (!root) return () => undefined;
|
||||
let frame: number | null = null;
|
||||
|
||||
function run() {
|
||||
frame = null;
|
||||
translateDomSubtree(root, language, dictionary);
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (frame !== null) return;
|
||||
frame = window.requestAnimationFrame(run);
|
||||
}
|
||||
|
||||
schedule();
|
||||
|
||||
const observer = new MutationObserver(schedule);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: TRANSLATABLE_ATTRIBUTES,
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
translateDomSubtree(root, PRIMARY_LANGUAGE, dictionary);
|
||||
};
|
||||
}
|
||||
|
||||
function translateDomSubtree(root: Element, language: string, dictionary: PlatformTranslationDictionary): void {
|
||||
translateElementAttributes(root, language, dictionary);
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
||||
let current = walker.nextNode();
|
||||
while (current) {
|
||||
if (current.nodeType === Node.TEXT_NODE) {
|
||||
translateTextNode(current as Text, language, dictionary);
|
||||
} else if (current.nodeType === Node.ELEMENT_NODE) {
|
||||
translateElementAttributes(current as Element, language, dictionary);
|
||||
}
|
||||
current = walker.nextNode();
|
||||
}
|
||||
}
|
||||
|
||||
function translateTextNode(node: Text, language: string, dictionary: PlatformTranslationDictionary): void {
|
||||
const parent = node.parentElement;
|
||||
if (!parent || parent.closest(SKIP_TRANSLATION_SELECTOR)) return;
|
||||
const current = node.nodeValue ?? "";
|
||||
if (!current.trim()) return;
|
||||
|
||||
const stored = textNodeOriginals.get(node);
|
||||
const knownTranslation = stored ? translatePlainText(stored, language, dictionary) : null;
|
||||
const original = stored && (current === stored || current === knownTranslation) ? stored : current;
|
||||
textNodeOriginals.set(node, original);
|
||||
|
||||
const next = translatePlainText(original, language, dictionary);
|
||||
if (current !== next) node.nodeValue = next;
|
||||
}
|
||||
|
||||
function translateElementAttributes(element: Element, language: string, dictionary: PlatformTranslationDictionary): void {
|
||||
if (element.closest(SKIP_TRANSLATION_SELECTOR)) return;
|
||||
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
|
||||
const current = element.getAttribute(attribute);
|
||||
if (!current?.trim()) continue;
|
||||
|
||||
const storageAttribute = `data-i18n-original-${attribute}`;
|
||||
const stored = element.getAttribute(storageAttribute);
|
||||
const knownTranslation = stored ? translatePlainText(stored, language, dictionary) : null;
|
||||
const original = stored && (current === stored || current === knownTranslation) ? stored : current;
|
||||
element.setAttribute(storageAttribute, original);
|
||||
|
||||
const next = translatePlainText(original, language, dictionary);
|
||||
if (current !== next) element.setAttribute(attribute, next);
|
||||
}
|
||||
}
|
||||
1118
webui/src/i18n/generatedTranslations.ts
Normal file
@@ -13,8 +13,14 @@ export * from "./utils/helpContext";
|
||||
export * from "./utils/emailAddresses";
|
||||
export * from "./utils/datetime";
|
||||
export * from "./utils/arrayOrder";
|
||||
export * from "./utils/delta";
|
||||
export * from "./utils/deltaHooks";
|
||||
export * from "./i18n/LanguageContext";
|
||||
|
||||
export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
|
||||
export { default as ActionBlockerHint } from "./components/ActionBlockerHint";
|
||||
export type { ActionBlockerReason } from "./components/ActionBlockerHint";
|
||||
export { default as AdvancedOptionsPanel } from "./components/AdvancedOptionsPanel";
|
||||
export { default as AdminIconButton } from "./components/admin/AdminIconButton";
|
||||
export { default as AdminPageLayout } from "./components/admin/AdminPageLayout";
|
||||
export { default as AdminSelectionList } from "./components/admin/AdminSelectionList";
|
||||
@@ -22,6 +28,10 @@ export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components
|
||||
export { default as Button } from "./components/Button";
|
||||
export { default as Card } from "./components/Card";
|
||||
export { default as ConfirmDialog } from "./components/ConfirmDialog";
|
||||
export { default as ConnectionTree } from "./components/ConnectionTree";
|
||||
export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree";
|
||||
export { default as ColorPickerField } from "./components/ColorPickerField";
|
||||
export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField";
|
||||
export { default as Dialog } from "./components/Dialog";
|
||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
||||
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
||||
@@ -29,24 +39,32 @@ export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBloc
|
||||
export { default as FileDropZone } from "./components/FileDropZone";
|
||||
export type { FileDropZoneProps } from "./components/FileDropZone";
|
||||
export { default as FormField } from "./components/FormField";
|
||||
export { default as GuidedConfigDialog } from "./components/GuidedConfigDialog";
|
||||
export type { GuidedConfigDialogProps } from "./components/GuidedConfigDialog";
|
||||
export { default as GuidedReviewList } from "./components/GuidedReviewList";
|
||||
export type { GuidedReviewItem } from "./components/GuidedReviewList";
|
||||
export { default as LoadingFrame } from "./components/LoadingFrame";
|
||||
export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
||||
export { default as ExplorerTree } from "./components/ExplorerTree";
|
||||
export type { ExplorerTreeNodeContext } from "./components/ExplorerTree";
|
||||
export type { ExplorerTreeNodeContext, ExplorerTreeProps } from "./components/ExplorerTree";
|
||||
export { default as MetricCard } from "./components/MetricCard";
|
||||
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
|
||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||
export { default as PageTitle } from "./components/PageTitle";
|
||||
export { default as PasswordField } from "./components/PasswordField";
|
||||
export type { PasswordFieldProps } from "./components/PasswordField";
|
||||
export { PolicyRow, PolicySection, PolicyTable } from "./components/PolicyTable";
|
||||
export { default as PolicySourcePath } from "./components/PolicySourcePath";
|
||||
export type { PolicySourcePathItem, PolicySourcePathProps } from "./components/PolicySourcePath";
|
||||
export { default as PolicyLockedHint } from "./components/PolicyLockedHint";
|
||||
export type { PolicyLockedHintProps } from "./components/PolicyLockedHint";
|
||||
export { default as SegmentedControl } from "./components/SegmentedControl";
|
||||
export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl";
|
||||
export { default as StatusBadge } from "./components/StatusBadge";
|
||||
export { default as Stepper } from "./components/Stepper";
|
||||
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
||||
export { UnsavedChangesProvider, useRegisterUnsavedChanges, useUnsavedChanges } from "./components/UnsavedChangesGuard";
|
||||
export { UnsavedChangesProvider, useGuardedNavigate, useRegisterUnsavedChanges, useUnsavedChanges, useUnsavedDraftGuard } from "./components/UnsavedChangesGuard";
|
||||
export type { UnsavedDraftGuardOptions } from "./components/UnsavedChangesGuard";
|
||||
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard";
|
||||
export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
|
||||
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
|
||||
@@ -65,6 +83,7 @@ export { default as AppShell } from "./layout/AppShell";
|
||||
export { default as BreadcrumbBar } from "./layout/BreadcrumbBar";
|
||||
export { default as HelpMenu } from "./layout/HelpMenu";
|
||||
export { default as IconRail } from "./layout/IconRail";
|
||||
export { default as LanguageMenu } from "./layout/LanguageMenu";
|
||||
export { default as ModuleSubnav } from "./layout/ModuleSubnav";
|
||||
export type { ModuleSubnavGroup, ModuleSubnavItem } from "./layout/ModuleSubnav";
|
||||
export { default as Titlebar } from "./layout/Titlebar";
|
||||
|
||||
@@ -12,6 +12,7 @@ type Props = {
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
publicMode?: boolean;
|
||||
navItems?: PlatformNavItem[];
|
||||
maintenanceMode?: { enabled: boolean; message?: string | null };
|
||||
};
|
||||
|
||||
export default function AppShell({
|
||||
@@ -21,7 +22,8 @@ export default function AppShell({
|
||||
onSettingsChange,
|
||||
onAuthChange,
|
||||
publicMode = false,
|
||||
navItems = []
|
||||
navItems = [],
|
||||
maintenanceMode
|
||||
}: Props) {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -30,7 +32,7 @@ export default function AppShell({
|
||||
<div className="app-shell public-shell">
|
||||
<IconRail compact auth={auth} navItems={navItems} />
|
||||
<div className="app-main public-main">
|
||||
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} />
|
||||
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} />
|
||||
<main className="public-content">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +43,7 @@ export default function AppShell({
|
||||
<div className="app-shell">
|
||||
<IconRail auth={auth} navItems={navItems} />
|
||||
<div className="app-main">
|
||||
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} />
|
||||
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} />
|
||||
<BreadcrumbBar pathname={location.pathname} />
|
||||
<main className="app-content">{children}</main>
|
||||
</div>
|
||||
|
||||
@@ -1,64 +1,66 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export default function BreadcrumbBar({ pathname }: { pathname: string }) {
|
||||
export default function BreadcrumbBar({ pathname }: {pathname: string;}) {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const labels = parts.length ? parts : ["campaigns"];
|
||||
const { translateText } = usePlatformLanguage();
|
||||
|
||||
return (
|
||||
<div className="breadcrumb-bar">
|
||||
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
||||
<nav className="breadcrumbs" aria-label={translateText("i18n:govoplan-core.breadcrumb.c766e665")}>
|
||||
{labels.map((part, index) => {
|
||||
const href = `/${labels.slice(0, index + 1).join("/")}`;
|
||||
return (
|
||||
<span className="crumb" key={`${part}-${index}`}>
|
||||
<Link className="crumb-link" to={href}>{labelFor(part, labels, index)}</Link>
|
||||
<Link className="crumb-link" to={href}>{translateText(labelFor(part, labels, index))}</Link>
|
||||
{index < labels.length - 1 && <ChevronRight size={16} aria-hidden="true" />}
|
||||
</span>
|
||||
);
|
||||
</span>);
|
||||
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
const campaignRouteLabels: Record<string, string> = {
|
||||
data: "Sender & Recipients",
|
||||
campaign: "Sender & Recipients",
|
||||
settings: "Campaign settings",
|
||||
"global-settings": "Campaign settings",
|
||||
fields: "Fields",
|
||||
recipients: "Sender & Recipients",
|
||||
"recipient-data": "Recipient data",
|
||||
template: "Template",
|
||||
files: "Attachments",
|
||||
attachments: "Attachments",
|
||||
mail: "Server settings",
|
||||
"mail-settings": "Server settings",
|
||||
"server-settings": "Server settings",
|
||||
review: "Review & Send",
|
||||
send: "Review & Send",
|
||||
report: "Report",
|
||||
reports: "Report",
|
||||
audit: "Audit log",
|
||||
json: "JSON",
|
||||
wizard: "Wizard",
|
||||
create: "Create",
|
||||
data: "i18n:govoplan-core.sender_recipients.922c6d24",
|
||||
campaign: "i18n:govoplan-core.sender_recipients.922c6d24",
|
||||
settings: "i18n:govoplan-core.campaign_settings.efffec26",
|
||||
"global-settings": "i18n:govoplan-core.campaign_settings.efffec26",
|
||||
fields: "i18n:govoplan-core.fields.e8b68527",
|
||||
recipients: "i18n:govoplan-core.sender_recipients.922c6d24",
|
||||
"recipient-data": "i18n:govoplan-core.recipient_data.c2baaf10",
|
||||
template: "i18n:govoplan-core.template.3ec1ae06",
|
||||
files: "i18n:govoplan-core.attachments.6771ade6",
|
||||
attachments: "i18n:govoplan-core.attachments.6771ade6",
|
||||
mail: "i18n:govoplan-core.server_settings.28af3cc7",
|
||||
"mail-settings": "i18n:govoplan-core.server_settings.28af3cc7",
|
||||
"server-settings": "i18n:govoplan-core.server_settings.28af3cc7",
|
||||
review: "i18n:govoplan-core.review_send.1627617d",
|
||||
send: "i18n:govoplan-core.review_send.1627617d",
|
||||
report: "i18n:govoplan-core.report.ee45c303",
|
||||
reports: "i18n:govoplan-core.report.ee45c303",
|
||||
audit: "i18n:govoplan-core.audit_log.3cfc5f1c",
|
||||
json: "i18n:govoplan-core.json.031a4e76",
|
||||
wizard: "i18n:govoplan-core.wizard.127a661b",
|
||||
create: "i18n:govoplan-core.create.6e157c5d"
|
||||
};
|
||||
|
||||
const topLevelRouteLabels: Record<string, string> = {
|
||||
campaigns: "Campaigns",
|
||||
dashboard: "Dashboard",
|
||||
templates: "Templates",
|
||||
files: "Files",
|
||||
"address-book": "Address Book",
|
||||
reports: "Reports",
|
||||
settings: "Settings",
|
||||
admin: "Admin",
|
||||
campaigns: "i18n:govoplan-core.campaigns.01a23a28",
|
||||
dashboard: "i18n:govoplan-core.dashboard.d87f47b4",
|
||||
templates: "i18n:govoplan-core.templates.f25b700e",
|
||||
files: "i18n:govoplan-core.files.6ce6c512",
|
||||
"address-book": "i18n:govoplan-core.address_book.f6327f59",
|
||||
reports: "i18n:govoplan-core.reports.88bc3fe3",
|
||||
settings: "i18n:govoplan-core.settings.c7f73bb5",
|
||||
admin: "i18n:govoplan-core.admin.4e7afebc"
|
||||
};
|
||||
|
||||
function labelFor(value: string, parts: string[], index: number): string {
|
||||
if (parts[0] === "campaigns" && index === 1) return "Campaign";
|
||||
if (parts[0] === "campaigns" && index === 1) return "i18n:govoplan-core.campaign.69390e16";
|
||||
if (parts[0] === "campaigns" && index >= 2) {
|
||||
const mapped = campaignRouteLabels[value];
|
||||
if (mapped) return mapped;
|
||||
@@ -66,6 +68,6 @@ function labelFor(value: string, parts: string[], index: number): string {
|
||||
|
||||
const mapped = topLevelRouteLabels[value];
|
||||
if (mapped) return mapped;
|
||||
if (value.length > 18) return "Campaign";
|
||||
if (value.length > 18) return "i18n:govoplan-core.campaign.69390e16";
|
||||
return value.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
|
||||
import packageInfo from "../../package.json";
|
||||
import Button from "../components/Button";
|
||||
import Dialog from "../components/Dialog";
|
||||
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
|
||||
import { usePlatformModules } from "../platform/ModuleContext";
|
||||
import type { PlatformWebModule } from "../types";
|
||||
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export default function HelpMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -14,8 +16,11 @@ export default function HelpMenu() {
|
||||
const [contextOpen, setContextOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
const navigate = useGuardedNavigate();
|
||||
const helpContext = helpContextForPathname(location.pathname);
|
||||
const modules = usePlatformModules();
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const docsAvailable = modules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
|
||||
|
||||
useEffect(() => {
|
||||
function openContextHelp(event: KeyboardEvent) {
|
||||
@@ -44,6 +49,14 @@ export default function HelpMenu() {
|
||||
setContextOpen(true);
|
||||
}
|
||||
|
||||
function openDocs(type: "user" | "admin") {
|
||||
if (!docsAvailable) return;
|
||||
setOpen(false);
|
||||
const params = new URLSearchParams({ type });
|
||||
params.set("context", helpContext.id);
|
||||
navigate(`/docs?${params.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="context-menu-wrap" ref={wrapRef}>
|
||||
<button
|
||||
@@ -56,68 +69,74 @@ export default function HelpMenu() {
|
||||
openHelp();
|
||||
}
|
||||
}}
|
||||
title="Help (F1)"
|
||||
>
|
||||
<HelpCircle size={17} /> Help
|
||||
title={translateText("i18n:govoplan-core.help_f1")}>
|
||||
|
||||
<HelpCircle size={17} /> {translateText("i18n:govoplan-core.help.c47ae153")}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="dropdown-menu">
|
||||
{open &&
|
||||
<div className="dropdown-menu">
|
||||
<button className="dropdown-item" onClick={openHelp}>
|
||||
<HelpCircle size={16} /> Help <small>F1</small>
|
||||
<HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small>
|
||||
</button>
|
||||
<hr />
|
||||
<a className="dropdown-item" href="#" onClick={(e) => e.preventDefault()}><BookOpen size={16} /> User docs</a>
|
||||
<a className="dropdown-item" href="#" onClick={(e) => e.preventDefault()}><BookOpen size={16} /> Admin docs</a>
|
||||
<button className="dropdown-item" onClick={() => openDocs("user")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}>
|
||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
|
||||
</button>
|
||||
<button className="dropdown-item" onClick={() => openDocs("admin")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}>
|
||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
|
||||
</button>
|
||||
<hr />
|
||||
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> GitLab</a>
|
||||
<button className="dropdown-item" onClick={() => { setAboutOpen(true); setOpen(false); }}><Info size={16} /> About</button>
|
||||
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> i18n:govoplan-core.gitlab.9a9b09d9</a>
|
||||
<button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
|
||||
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose: () => void }) {
|
||||
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Context help"
|
||||
title={translateText("i18n:govoplan-core.context_help.7fc44ea1")}
|
||||
onClose={onClose}
|
||||
footer={<Button variant="primary" onClick={onClose}>Close</Button>}
|
||||
>
|
||||
footer={<Button variant="primary" onClick={onClose}>{translateText("i18n:govoplan-core.close.bbfa773e")}</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>
|
||||
<h3>{translateText(context.title)}</h3>
|
||||
<p className="mono-small">{translateText("i18n:govoplan-core.help_context.61aed3b9")} {context.id}</p>
|
||||
<p className="muted">{translateText("i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877")} <span className="kbd">{helpQueryForContext(context)}</span> {translateText("i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2")}</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>
|
||||
<h3>{translateText("i18n:govoplan-core.next_actions.7b09055a")}</h3>
|
||||
<p className="muted">{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
|
||||
function AboutModal({ modules, onClose }: { modules: PlatformWebModule[]; onClose: () => void }) {
|
||||
const moduleSummary = modules.length
|
||||
? modules.map((module) => `${module.label} v${module.version}`).join(", ")
|
||||
: "Core shell only";
|
||||
function AboutModal({ modules, onClose }: {modules: PlatformWebModule[];onClose: () => void;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const moduleSummary = modules.length ?
|
||||
modules.map((module) => `${module.label} v${module.version}`).join(", ") :
|
||||
translateText("i18n:govoplan-core.core_shell_only.4949b5d5");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="About GovOPlaN"
|
||||
title={translateText("i18n:govoplan-core.about_govoplan.1c6884f8")}
|
||||
onClose={onClose}
|
||||
footer={<Button variant="primary" onClick={onClose}>Close</Button>}
|
||||
>
|
||||
footer={<Button variant="primary" onClick={onClose}>{translateText("i18n:govoplan-core.close.bbfa773e")}</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><strong>i18n:govoplan-core.govoplan_webui.d4ff003b</strong></p>
|
||||
<p className="muted">{translateText("i18n:govoplan-core.core_webui_version.a3c761e1")} {packageInfo.version}</p>
|
||||
<p>{translateText("i18n:govoplan-core.govoplan_is_a_modular_platform_runner_for_govern.159abeab")}</p>
|
||||
<p className="muted">{translateText("i18n:govoplan-core.installed_modules.cd6863dd")} {moduleSummary}</p>
|
||||
<p><a href="https://add-ideas.de" target="_blank" rel="noreferrer">add-ideas.de</a></p>
|
||||
</Dialog>
|
||||
);
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import type { AuthInfo, PlatformNavItem } from "../types";
|
||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
|
||||
|
||||
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
|
||||
|
||||
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
|
||||
return [...navItems]
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100))
|
||||
.filter((item) => {
|
||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
||||
return true;
|
||||
});
|
||||
return [...navItems].
|
||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100)).
|
||||
filter((item) => {
|
||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export default function IconRail({
|
||||
compact = false,
|
||||
auth = null,
|
||||
navItems = []
|
||||
}: {
|
||||
compact?: boolean;
|
||||
auth?: AuthInfo | null;
|
||||
navItems?: PlatformNavItem[];
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
}: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) {
|
||||
const location = useLocation();
|
||||
const items = visibleNavItems(auth, navItems);
|
||||
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
|
||||
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const navigate = useGuardedNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const currentRoot = topLevelItems.find((root) => modulePathActive(location.pathname, root));
|
||||
@@ -42,32 +46,38 @@ export default function IconRail({
|
||||
});
|
||||
}, [location.hash, location.pathname, location.search, topLevelItems]);
|
||||
|
||||
function handleNavClick(event: MouseEvent<HTMLAnchorElement>, target: string) {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
navigate(target);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
|
||||
<div className="brand-mark" title="GovOPlaN">G</div>
|
||||
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
|
||||
|
||||
{!compact && (
|
||||
<>
|
||||
{!compact &&
|
||||
<>
|
||||
<nav className="icon-nav">
|
||||
{items.map(({ to, label, icon: Icon }) => {
|
||||
const target = rememberedTargets[to] ?? to;
|
||||
const active = modulePathActive(location.pathname, to);
|
||||
return (
|
||||
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={label}>
|
||||
const target = rememberedTargets[to] ?? to;
|
||||
const active = modulePathActive(location.pathname, to);
|
||||
return (
|
||||
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={translateText(label)} onClick={(event) => handleNavClick(event, target)}>
|
||||
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</NavLink>);
|
||||
|
||||
})}
|
||||
</nav>
|
||||
<div className="icon-rail-bottom">
|
||||
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title="Settings">
|
||||
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
|
||||
<Settings size={20} />
|
||||
</NavLink>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
</aside>);
|
||||
|
||||
}
|
||||
|
||||
function modulePathActive(pathname: string, root: string): boolean {
|
||||
@@ -85,7 +95,7 @@ function loadRememberedTargets(): Record<string, string> {
|
||||
Object.entries(parsed).filter((entry): entry is [string, string] => {
|
||||
const [root, target] = entry;
|
||||
return typeof root === "string" && typeof target === "string" && root.startsWith("/") && target.startsWith(`${root}/`);
|
||||
}),
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
@@ -97,6 +107,7 @@ function saveRememberedTargets(targets: Record<string, string>): void {
|
||||
try {
|
||||
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
|
||||
} catch {
|
||||
|
||||
|
||||
// Remembered navigation is a convenience only.
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
48
webui/src/layout/LanguageMenu.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export default function LanguageMenu() {
|
||||
const { language, selectableLanguages, setLanguage } = usePlatformLanguage();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
if (menuRef.current && !menuRef.current.contains(target)) setOpen(false);
|
||||
}
|
||||
window.addEventListener("mousedown", onPointerDown);
|
||||
return () => window.removeEventListener("mousedown", onPointerDown);
|
||||
}, []);
|
||||
|
||||
if (selectableLanguages.length <= 1) return null;
|
||||
|
||||
function selectLanguage(code: string) {
|
||||
setLanguage(code);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="context-menu-wrap language-menu-wrap" ref={menuRef}>
|
||||
<button className="titlebar-link language-menu-button" onClick={() => setOpen(!open)} aria-haspopup="menu" aria-expanded={open}>
|
||||
<span className="language-menu-code">{language.toUpperCase()}</span>
|
||||
<span className="tenant-caret">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="dropdown-menu language-menu" role="menu">
|
||||
{selectableLanguages.map((item) => {
|
||||
const active = item.code === language;
|
||||
return (
|
||||
<button key={item.code} className={`dropdown-item ${active ? "active" : ""}`} onClick={() => selectLanguage(item.code)} role="menuitem">
|
||||
<span className="language-option-code">{item.code.toUpperCase()}</span>
|
||||
<span>{item.nativeLabel || item.label}</span>
|
||||
{active && <Check size={16} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export type ModuleSubnavItem<T extends string> = {
|
||||
id: T;
|
||||
label: string;
|
||||
@@ -29,34 +31,35 @@ export default function ModuleSubnav<T extends string>({
|
||||
groups,
|
||||
onSelect,
|
||||
className = ""
|
||||
}: {
|
||||
active: T;
|
||||
groups: ModuleSubnavGroup<T>[];
|
||||
onSelect: (section: T) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {active: T;groups: ModuleSubnavGroup<T>[];onSelect: (section: T) => void;className?: string;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return (
|
||||
<aside className={`section-sidebar ${className}`.trim()}>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div className="section-group" key={`${group.title ?? "group"}-${groupIndex}`}>
|
||||
{group.title && <div className={`section-title ${groupIndex > 0 ? "section-title-lower" : ""}`.trim()}>{group.title}</div>}
|
||||
{groups.map((group, groupIndex) =>
|
||||
<div className="section-group" key={`${group.title ?? "group"}-${groupIndex}`}>
|
||||
{group.title && <div className={`section-title ${groupIndex > 0 ? "section-title-lower" : ""}`.trim()}>{translateText(group.title)}</div>}
|
||||
{group.items.map((entry) => {
|
||||
const key = isAction(entry) ? entry.actionId : entry.id;
|
||||
const activeClass = !isAction(entry) && active === entry.id ? " active" : "";
|
||||
const primaryClass = entry.primary ? " section-link-primary" : "";
|
||||
const subtleClass = entry.subtle ? " subtle" : "";
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className={`section-link${primaryClass}${subtleClass}${activeClass}`}
|
||||
onClick={() => (isAction(entry) ? entry.onClick() : onSelect(entry.id))}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
const key = isAction(entry) ? entry.actionId : entry.id;
|
||||
const activeClass = !isAction(entry) && active === entry.id ? " active" : "";
|
||||
const primaryClass = entry.primary ? " section-link-primary" : "";
|
||||
const subtleClass = entry.subtle ? " subtle" : "";
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className={`section-link${primaryClass}${subtleClass}${activeClass}`}
|
||||
onClick={() => isAction(entry) ? entry.onClick() : onSelect(entry.id)}>
|
||||
|
||||
{translateText(entry.label)}
|
||||
</button>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
)}
|
||||
</aside>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from "../types";
|
||||
import HelpMenu from "./HelpMenu";
|
||||
import LanguageMenu from "./LanguageMenu";
|
||||
import LoginModal from "../features/auth/LoginModal";
|
||||
import DismissibleAlert from "../components/DismissibleAlert";
|
||||
import { useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
||||
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
||||
import { logout, switchTenant } from "../api/auth";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo | null;
|
||||
onSettingsChange: (settings: ApiSettings) => void;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
maintenanceMode?: {enabled: boolean;message?: string | null;};
|
||||
};
|
||||
|
||||
export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
const navigate = useNavigate();
|
||||
export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode }: Props) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
const [tenantOpen, setTenantOpen] = useState(false);
|
||||
@@ -26,16 +28,18 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
const [tenantError, setTenantError] = useState("");
|
||||
const accountRef = useRef<HTMLDivElement>(null);
|
||||
const tenantRef = useRef<HTMLDivElement>(null);
|
||||
const { translateText } = usePlatformLanguage();
|
||||
|
||||
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||
const displayUserName = auth?.user?.display_name || auth?.user?.email || translateText("i18n:govoplan-core.sign_in.ada2e9e9");
|
||||
const canSwitchTenant = tenants.length > 1;
|
||||
const canAdministerTenants = hasAnyScope(auth, [
|
||||
"system:tenants:read",
|
||||
"system:tenants:create",
|
||||
"system:tenants:update",
|
||||
"system:tenants:suspend"
|
||||
]);
|
||||
"system:tenants:read",
|
||||
"system:tenants:create",
|
||||
"system:tenants:update",
|
||||
"system:tenants:suspend"]
|
||||
);
|
||||
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,25 +57,14 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
}, []);
|
||||
|
||||
function handleLogin(response: LoginResponse) {
|
||||
const active = response.active_tenant ?? response.tenant;
|
||||
onAuthChange(
|
||||
{
|
||||
user: response.user,
|
||||
tenant: active,
|
||||
active_tenant: active,
|
||||
tenants: response.tenants ?? [active],
|
||||
scopes: response.scopes,
|
||||
roles: response.roles,
|
||||
groups: response.groups
|
||||
},
|
||||
""
|
||||
);
|
||||
onAuthChange(response, "");
|
||||
}
|
||||
|
||||
async function performLogout() {
|
||||
try {
|
||||
await logout(settings);
|
||||
} catch {
|
||||
|
||||
// Logout is best effort; clear local session either way.
|
||||
}
|
||||
onAuthChange(null, "");
|
||||
@@ -89,14 +82,14 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
onAuthChange(next);
|
||||
setTenantOpen(false);
|
||||
} catch (error) {
|
||||
setTenantError(error instanceof Error ? error.message : "Tenant switch failed.");
|
||||
setTenantError(error instanceof Error ? error.message : translateText("i18n:govoplan-core.tenant_switch_failed.c325b20b"));
|
||||
} finally {
|
||||
setSwitchingTenantId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
requestNavigation(() => { void performLogout(); });
|
||||
requestNavigation(() => {void performLogout();});
|
||||
}
|
||||
|
||||
function handleTenantSelect(tenant: AuthTenantMembership) {
|
||||
@@ -104,80 +97,98 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
setTenantOpen(false);
|
||||
return;
|
||||
}
|
||||
requestNavigation(() => { void performTenantSelect(tenant); });
|
||||
requestNavigation(() => {void performTenantSelect(tenant);});
|
||||
}
|
||||
|
||||
function handleAccountSettings() {
|
||||
setAccountOpen(false);
|
||||
requestNavigation(() => navigate("/settings?section=profile"));
|
||||
navigate("/settings?section=profile");
|
||||
}
|
||||
|
||||
function openMaintenanceSettings() {
|
||||
navigate("/admin?section=system-settings");
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="titlebar">
|
||||
{auth && activeTenant && showTenantControl && (
|
||||
<div className="tenant-selector" ref={tenantRef}>
|
||||
<span className="tenant-label">tenant:</span>
|
||||
{canSwitchTenant ? (
|
||||
<>
|
||||
{maintenanceMode?.enabled &&
|
||||
<button
|
||||
type="button"
|
||||
className="maintenance-topbar-link"
|
||||
title={maintenanceMode.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
|
||||
onClick={openMaintenanceSettings}>
|
||||
|
||||
{translateText("i18n:govoplan-core.maintenance_mode_enabled.4fb4a37d")}
|
||||
</button>
|
||||
}
|
||||
|
||||
{auth && activeTenant && showTenantControl &&
|
||||
<div className="tenant-selector" ref={tenantRef}>
|
||||
<span className="tenant-label">{translateText("i18n:govoplan-core.tenant_label_prefix")}</span>
|
||||
{canSwitchTenant ?
|
||||
<>
|
||||
<button className="tenant-name-button" onClick={() => setTenantOpen(!tenantOpen)}>
|
||||
<strong>{activeTenant.name}</strong>
|
||||
<span className="tenant-caret">▾</span>
|
||||
</button>
|
||||
{tenantOpen && (
|
||||
<div className="dropdown-menu tenant-menu">
|
||||
{tenantOpen &&
|
||||
<div className="dropdown-menu tenant-menu">
|
||||
{tenants.map((tenant) => {
|
||||
const active = tenant.id === activeTenant.id;
|
||||
return (
|
||||
<button
|
||||
key={tenant.id}
|
||||
className={`dropdown-item ${active ? "active" : ""}`}
|
||||
onClick={() => void handleTenantSelect(tenant)}
|
||||
disabled={Boolean(switchingTenantId)}
|
||||
>
|
||||
<span>{switchingTenantId === tenant.id ? "Switching…" : tenant.name}</span>
|
||||
const active = tenant.id === activeTenant.id;
|
||||
return (
|
||||
<button
|
||||
key={tenant.id}
|
||||
className={`dropdown-item ${active ? "active" : ""}`}
|
||||
onClick={() => void handleTenantSelect(tenant)}
|
||||
disabled={Boolean(switchingTenantId)}>
|
||||
|
||||
<span>{switchingTenantId === tenant.id ? translateText("i18n:govoplan-core.switching.ca40b0ec") : tenant.name}</span>
|
||||
{active && <Check size={16} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</button>);
|
||||
|
||||
})}
|
||||
{tenantError && <DismissibleAlert tone="danger" compact dismissible={false} className="dropdown-error">{tenantError}</DismissibleAlert>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<strong>{activeTenant.name}</strong>
|
||||
)}
|
||||
}
|
||||
</> :
|
||||
|
||||
<strong>{activeTenant.name}</strong>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
<div className="titlebar-spacer" />
|
||||
|
||||
<HelpMenu />
|
||||
<LanguageMenu />
|
||||
|
||||
<div className="context-menu-wrap" ref={accountRef}>
|
||||
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
|
||||
<UserCircle size={22} />
|
||||
<span>{auth?.user.display_name || auth?.user.email || "Sign in"}</span>
|
||||
<span>{displayUserName}</span>
|
||||
<span className="tenant-caret">▾</span>
|
||||
</button>
|
||||
{accountOpen && (
|
||||
<div className="dropdown-menu account-menu">
|
||||
{auth ? (
|
||||
<>
|
||||
{accountOpen &&
|
||||
<div className="dropdown-menu account-menu">
|
||||
{auth ?
|
||||
<>
|
||||
{auth.user &&
|
||||
<div className="account-menu-header">
|
||||
<strong>{auth.user.display_name || auth.user.email}</strong>
|
||||
<span>{auth.user.email}</span>
|
||||
</div>
|
||||
<button className="dropdown-item" onClick={handleAccountSettings}><Settings size={16} /> Account settings</button>
|
||||
<button className="dropdown-item" onClick={handleLogout}><LogOut size={16} /> Sign out</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="dropdown-item" onClick={() => { setLoginOpen(true); setAccountOpen(false); }}><UserCircle size={16} /> Sign in</button>
|
||||
)}
|
||||
}
|
||||
<button className="dropdown-item" onClick={handleAccountSettings}><Settings size={16} /> {translateText("i18n:govoplan-core.account_settings.82cf8a5f")}</button>
|
||||
<button className="dropdown-item" onClick={handleLogout}><LogOut size={16} /> {translateText("i18n:govoplan-core.sign_out.dc1649a1")}</button>
|
||||
</> :
|
||||
|
||||
<button className="dropdown-item" onClick={() => {setLoginOpen(true);setAccountOpen(false);}}><UserCircle size={16} /> {translateText("i18n:govoplan-core.sign_in.ada2e9e9")}</button>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
|
||||
{loginOpen && <LoginModal settings={settings} onClose={() => setLoginOpen(false)} onLogin={handleLogin} />}
|
||||
</header>
|
||||
);
|
||||
</header>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Activity, CalendarDays, FileText, Folder, Form, LayoutDashboard, Mail, Mails, Shield, Users, type LucideIcon } from "lucide-react";
|
||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
||||
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
||||
import {
|
||||
hasUiCapability as hasUiCapabilityForModules,
|
||||
moduleInstalled as moduleInstalledForModules,
|
||||
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
||||
routeContributionsForModules as routeContributionsForModuleList,
|
||||
uiCapabilities as uiCapabilitiesForModules,
|
||||
uiCapability as uiCapabilityForModules
|
||||
} from "./moduleLogic";
|
||||
uiCapability as uiCapabilityForModules } from
|
||||
"./moduleLogic";
|
||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||
|
||||
export const shellNavItems: PlatformNavItem[] = [
|
||||
{ to: "/dashboard", label: "Dashboard", iconName: "dashboard", order: 10 }
|
||||
];
|
||||
export const fallbackDashboardNavItem: PlatformNavItem =
|
||||
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
|
||||
|
||||
export function shellNavItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
||||
return moduleInstalledForModules("dashboard", modules) ? [] : [fallbackDashboardNavItem];
|
||||
}
|
||||
|
||||
|
||||
const localModules: PlatformWebModule[] = installedWebModules;
|
||||
const remoteModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
|
||||
@@ -104,19 +108,19 @@ export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[]
|
||||
if (!platformModules?.length) return localModules;
|
||||
|
||||
const localById = new Map(localModules.map((module) => [module.id, module]));
|
||||
return platformModules
|
||||
.filter((module) => module.enabled)
|
||||
.map((module) => {
|
||||
const local = localById.get(module.id);
|
||||
return local ? applyServerMetadata(local, module) : null;
|
||||
})
|
||||
.filter((module): module is PlatformWebModule => module !== null);
|
||||
return platformModules.
|
||||
filter((module) => module.enabled).
|
||||
map((module) => {
|
||||
const local = localById.get(module.id);
|
||||
return local ? applyServerMetadata(local, module) : null;
|
||||
}).
|
||||
filter((module): module is PlatformWebModule => module !== null);
|
||||
}
|
||||
|
||||
export async function loadRemoteWebModules(
|
||||
platformModules: PlatformModuleInfo[] | null | undefined,
|
||||
alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules)
|
||||
): Promise<PlatformWebModule[]> {
|
||||
platformModules: PlatformModuleInfo[] | null | undefined,
|
||||
alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules))
|
||||
: Promise<PlatformWebModule[]> {
|
||||
if (!platformModules?.length) return [];
|
||||
const loadedIds = new Set(alreadyLoaded.map((module) => module.id));
|
||||
const candidates = platformModules.filter((module) => {
|
||||
@@ -147,7 +151,7 @@ async function loadRemoteWebModule(info: PlatformModuleInfo): Promise<PlatformWe
|
||||
const manifestBuffer = await manifestResponse.arrayBuffer();
|
||||
if (frontend.asset_manifest_integrity) {
|
||||
const ok = await verifyIntegrity(manifestBuffer, frontend.asset_manifest_integrity);
|
||||
if (!ok) throw new Error("manifest integrity check failed");
|
||||
if (!ok) throw new Error("i18n:govoplan-core.manifest_integrity_check_failed.6e3913bc");
|
||||
}
|
||||
const manifestText = new TextDecoder().decode(manifestBuffer);
|
||||
if (frontend.asset_manifest_signature && frontend.asset_manifest_public_key_id) {
|
||||
@@ -156,28 +160,28 @@ async function loadRemoteWebModule(info: PlatformModuleInfo): Promise<PlatformWe
|
||||
frontend.asset_manifest_signature,
|
||||
frontend.asset_manifest_public_key_id
|
||||
);
|
||||
if (signatureOk === false) throw new Error("manifest signature check failed");
|
||||
if (signatureOk === null && !frontend.asset_manifest_integrity) throw new Error("manifest signature key is not trusted by this shell");
|
||||
if (signatureOk === false) throw new Error("i18n:govoplan-core.manifest_signature_check_failed.a9860176");
|
||||
if (signatureOk === null && !frontend.asset_manifest_integrity) throw new Error("i18n:govoplan-core.manifest_signature_key_is_not_trusted_by_this_sh.3bb2f629");
|
||||
} else if (!frontend.asset_manifest_integrity) {
|
||||
throw new Error("remote manifests need an integrity hash or a verifiable signature");
|
||||
throw new Error("i18n:govoplan-core.remote_manifests_need_an_integrity_hash_or_a_ver.bfbb7daa");
|
||||
}
|
||||
const manifest = JSON.parse(manifestText) as RemoteAssetManifest;
|
||||
const contractVersion = manifest.contractVersion ?? frontend.asset_manifest_contract_version ?? "1";
|
||||
if (contractVersion !== "1") throw new Error(`unsupported remote asset contract ${contractVersion}`);
|
||||
if (manifest.moduleId && manifest.moduleId !== info.id) throw new Error(`manifest module id ${manifest.moduleId} does not match ${info.id}`);
|
||||
if (!manifest.entry) throw new Error("remote manifest has no entry");
|
||||
if (!manifest.entryIntegrity) throw new Error("remote entry needs an integrity hash");
|
||||
if (!manifest.entry) throw new Error("i18n:govoplan-core.remote_manifest_has_no_entry.8220dbc9");
|
||||
if (!manifest.entryIntegrity) throw new Error("i18n:govoplan-core.remote_entry_needs_an_integrity_hash.3b69ae0a");
|
||||
|
||||
const entryUrl = absoluteModuleAssetUrl(manifest.entry, manifestUrl);
|
||||
const entryResponse = await fetch(entryUrl, { credentials: "same-origin" });
|
||||
if (!entryResponse.ok) throw new Error(`entry fetch failed with HTTP ${entryResponse.status}`);
|
||||
const entryBuffer = await entryResponse.arrayBuffer();
|
||||
if (!(await verifyIntegrity(entryBuffer, manifest.entryIntegrity))) throw new Error("entry integrity check failed");
|
||||
if (!(await verifyIntegrity(entryBuffer, manifest.entryIntegrity))) throw new Error("i18n:govoplan-core.entry_integrity_check_failed.1d8f6b89");
|
||||
const blobUrl = URL.createObjectURL(new Blob([entryBuffer], { type: "text/javascript" }));
|
||||
try {
|
||||
const imported = await import(/* @vite-ignore */ blobUrl) as Record<string, unknown>;
|
||||
const imported = (await import(/* @vite-ignore */blobUrl)) as Record<string, unknown>;
|
||||
const exported = manifest.moduleExport ? imported[manifest.moduleExport] : imported.default ?? imported.module;
|
||||
if (!isPlatformWebModule(exported)) throw new Error("entry did not export a PlatformWebModule");
|
||||
if (!isPlatformWebModule(exported)) throw new Error("i18n:govoplan-core.entry_did_not_export_a_platformwebmodule.9a157741");
|
||||
if (exported.id !== info.id) throw new Error(`entry module id ${exported.id} does not match ${info.id}`);
|
||||
return applyServerMetadata(exported, info);
|
||||
} finally {
|
||||
@@ -278,10 +282,16 @@ export function routeContributionsForModules(modules: PlatformWebModule[]) {
|
||||
return routeContributionsForModuleList(modules);
|
||||
}
|
||||
|
||||
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
||||
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
||||
flatMap((capability) => capability.widgets ?? []).
|
||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
||||
return [...shellNavItems, ...modules.flatMap((module) => module.navItems ?? [])]
|
||||
.map(resolveNavItemIcon)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
return [...shellNavItemsForModules(modules), ...modules.flatMap((module) => module.navItems ?? [])].
|
||||
map(resolveNavItemIcon).
|
||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules): PlatformNavItem[] {
|
||||
@@ -293,7 +303,5 @@ export function visibleNavItems(auth: AuthInfo | null | undefined, modules: Plat
|
||||
}
|
||||
|
||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules): string {
|
||||
const preferred = visibleNavItems(auth, modules).find((item) => item.to !== "/dashboard");
|
||||
if (preferred) return preferred.to;
|
||||
return "/dashboard";
|
||||
return visibleNavItems(auth, modules)[0]?.to ?? "/dashboard";
|
||||
}
|
||||
|
||||
@@ -232,6 +232,193 @@
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.color-picker-field,
|
||||
.date-field,
|
||||
.time-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-picker-field input {
|
||||
width: 100%;
|
||||
padding-left: 36px;
|
||||
padding-right: 38px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.date-field input,
|
||||
.time-field input {
|
||||
width: 100%;
|
||||
padding-right: 38px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.color-picker-preview {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
z-index: 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--line-dark);
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
|
||||
}
|
||||
|
||||
.color-picker-trigger,
|
||||
.date-field-trigger {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-picker-trigger:hover:not(:disabled),
|
||||
.color-picker-trigger:focus-visible,
|
||||
.date-field-trigger:hover:not(:disabled),
|
||||
.date-field-trigger:focus-visible {
|
||||
background: rgba(0,0,0,.08);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.time-field-icon {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.color-picker-popover,
|
||||
.date-field-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 80;
|
||||
width: max-content;
|
||||
min-width: 220px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line-dark);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 16px 34px rgba(0,0,0,.18);
|
||||
}
|
||||
|
||||
.color-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 28px);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-picker-grid button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(0,0,0,.2);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
|
||||
}
|
||||
|
||||
.color-picker-grid button.is-selected {
|
||||
outline: 2px solid var(--text-strong);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.date-field-popover {
|
||||
min-width: 268px;
|
||||
}
|
||||
|
||||
.date-field-monthbar {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 32px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.date-field-monthbar strong {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-field-monthbar button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-field-weekdays,
|
||||
.date-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.date-field-weekdays span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-field-grid {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.date-field-grid button {
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-field-grid button:hover:not(:disabled),
|
||||
.date-field-grid button:focus-visible {
|
||||
border-color: var(--line-dark);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.date-field-grid button.is-muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.date-field-grid button.is-selected {
|
||||
border-color: #5aa99b;
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.date-field-grid button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .35;
|
||||
}
|
||||
|
||||
.date-time-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 110px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
@@ -819,6 +1006,334 @@
|
||||
}
|
||||
}
|
||||
|
||||
.guided-config-dialog.admin-dialog-wide {
|
||||
width: min(1120px, calc(100vw - 40px));
|
||||
height: min(760px, calc(100vh - 40px));
|
||||
}
|
||||
|
||||
.guided-config-dialog-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.guided-config-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 282px minmax(0, 1fr);
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: min(620px, calc(100vh - 190px));
|
||||
}
|
||||
|
||||
.guided-config-sidebar {
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: #f6f5f3;
|
||||
}
|
||||
|
||||
.guided-config-sidebar .stepper {
|
||||
height: 100%;
|
||||
border-right: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.guided-config-content {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.guided-config-intro {
|
||||
max-width: 760px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.guided-config-intro p,
|
||||
.guided-config-step-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.guided-config-step-heading {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.guided-config-step-heading h3 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.guided-config-step-heading p {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.guided-config-step-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.adaptive-config-dialog.admin-dialog-wide {
|
||||
width: min(1040px, calc(100vw - 40px));
|
||||
}
|
||||
|
||||
.adaptive-config-dialog-body {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.adaptive-config-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.adaptive-config-section {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
padding: 15px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.adaptive-config-section > header {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.adaptive-config-section > header h3 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.adaptive-config-section > header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.advanced-options-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.advanced-options-panel > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text-strong);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.advanced-options-panel > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.advanced-options-panel > summary svg {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted);
|
||||
transition: transform .16s ease;
|
||||
}
|
||||
|
||||
.advanced-options-panel[open] > summary svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.advanced-options-summary {
|
||||
margin: -2px 12px 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.advanced-options-body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.action-blocker-hint {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 12px 13px;
|
||||
border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--line));
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--amber) 12%, var(--panel));
|
||||
}
|
||||
|
||||
.action-blocker-hint.tone-info {
|
||||
border-color: color-mix(in srgb, var(--blue) 45%, var(--line));
|
||||
background: color-mix(in srgb, var(--blue) 10%, var(--panel));
|
||||
}
|
||||
|
||||
.action-blocker-hint.tone-danger {
|
||||
border-color: color-mix(in srgb, var(--red) 45%, var(--line));
|
||||
background: color-mix(in srgb, var(--red) 10%, var(--panel));
|
||||
}
|
||||
|
||||
.action-blocker-icon {
|
||||
margin-top: 1px;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.action-blocker-hint.tone-info .action-blocker-icon {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.action-blocker-hint.tone-danger .action-blocker-icon {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.action-blocker-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.action-blocker-copy strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.action-blocker-copy p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.action-blocker-copy dl {
|
||||
display: grid;
|
||||
grid-template-columns: 126px minmax(0, 1fr);
|
||||
gap: 5px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-blocker-copy dt {
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.action-blocker-copy dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.action-blocker-technical {
|
||||
background: rgba(255,255,255,.56);
|
||||
}
|
||||
|
||||
.guided-review-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.guided-review-list.is-empty {
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.guided-review-row {
|
||||
display: grid;
|
||||
grid-template-columns: 190px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-left-width: 4px;
|
||||
border-left-color: var(--line-dark);
|
||||
border-radius: 7px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.guided-review-row.tone-success {
|
||||
border-left-color: var(--green);
|
||||
}
|
||||
|
||||
.guided-review-row.tone-warning {
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
.guided-review-row.tone-danger {
|
||||
border-left-color: var(--red);
|
||||
}
|
||||
|
||||
.guided-review-row dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.guided-review-row dd {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.guided-review-row dd strong,
|
||||
.guided-review-row dd span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.guided-review-row dd strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.guided-review-row dd span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.guided-config-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.guided-config-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.guided-config-sidebar .stepper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.guided-review-row,
|
||||
.action-blocker-copy dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mail-style address editor: textarea surface + pills + add dialog. */
|
||||
.email-address-editor {
|
||||
position: relative;
|
||||
@@ -1071,6 +1586,9 @@
|
||||
flex: 0 0 auto;
|
||||
outline: none;
|
||||
}
|
||||
.ui-hide-help-hints .inline-help {
|
||||
display: none;
|
||||
}
|
||||
.inline-help-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1105,6 +1623,15 @@
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.policy-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.policy-section h3 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.policy-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@@ -1151,6 +1678,10 @@
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.policy-effective-value.policy-effective-note {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.policy-effective-note {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1226,6 +1757,38 @@
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-input-missing {
|
||||
border-color: #b42318 !important;
|
||||
box-shadow: 0 0 0 3px rgba(180, 35, 24, .14) !important;
|
||||
}
|
||||
|
||||
.disabled-action-tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):hover::after,
|
||||
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):focus-within::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 10px);
|
||||
z-index: 80;
|
||||
width: min(280px, 72vw);
|
||||
padding: 9px 11px;
|
||||
border: 1px solid rgba(180, 35, 24, .24);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
color: #7a241c;
|
||||
background: #fff7f5;
|
||||
box-shadow: var(--shadow-popover, 0 16px 32px rgba(15, 23, 42, .18));
|
||||
content: attr(data-tooltip);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
}
|
||||
.loading-frame {
|
||||
position: relative;
|
||||
}
|
||||
@@ -1453,6 +2016,222 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
|
||||
}
|
||||
|
||||
.file-tree-heading {
|
||||
flex: 0 0 auto;
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.file-tree-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
.file-tree-space + .file-tree-space {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.file-tree-node-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.file-tree-node,
|
||||
.file-tree-select,
|
||||
.file-tree-toggle {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-tree-toggle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.file-tree-toggle:disabled {
|
||||
cursor: default;
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.file-tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 8px 9px;
|
||||
border-radius: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-tree-node span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-tree-root {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.file-tree-select {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-strong);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.file-tree-node-wrap:hover,
|
||||
.file-tree-node-wrap:focus-within,
|
||||
.file-tree-node-wrap.is-active,
|
||||
.file-tree-root:hover:not(:disabled),
|
||||
.file-tree-root:focus-visible,
|
||||
.file-tree-root.is-active {
|
||||
background: var(--line);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-tree-node:focus-visible,
|
||||
.file-tree-toggle:focus-visible,
|
||||
.file-tree-select:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-tree-node-wrap.is-drop-target,
|
||||
.file-tree-root.is-drop-target {
|
||||
background: var(--line);
|
||||
box-shadow: inset 0 0 0 2px var(--line-dark);
|
||||
}
|
||||
|
||||
.file-tree-node-wrap.is-selected .file-tree-select {
|
||||
background: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.file-tree-children {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.segmented-control {
|
||||
display: inline-grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, auto);
|
||||
justify-content: start;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid #c9c3b9;
|
||||
border-radius: 8px;
|
||||
background: #c9c3b9;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
||||
}
|
||||
|
||||
.segmented-control-size-content {
|
||||
grid-auto-columns: auto;
|
||||
}
|
||||
|
||||
.segmented-control-size-equal {
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.segmented-control-width-inline {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.segmented-control-width-fill {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segmented-control-option {
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(#ffffff, #f1efeb);
|
||||
color: #4f4a43;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
||||
transition: background .18s ease, box-shadow .18s ease, color .18s ease;
|
||||
}
|
||||
|
||||
.segmented-control-option + .segmented-control-option {
|
||||
border-left: 1px solid #c9c3b9;
|
||||
}
|
||||
|
||||
.segmented-control-option:first-child {
|
||||
border-radius: 7px 0 0 7px;
|
||||
}
|
||||
|
||||
.segmented-control-option:last-child {
|
||||
border-radius: 0 7px 7px 0;
|
||||
}
|
||||
|
||||
.segmented-control-option:hover,
|
||||
.segmented-control-option:focus-visible {
|
||||
background: linear-gradient(#ffffff, #e8e5df);
|
||||
color: #4f4a43;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
.segmented-control-option:focus-visible {
|
||||
outline: 3px solid rgba(82, 130, 177, .22);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.segmented-control-option.is-active,
|
||||
.segmented-control-option.active {
|
||||
background: var(--line);
|
||||
color: var(--text-strong);
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.12), inset 0 -1px 0 rgba(255,255,255,.45);
|
||||
}
|
||||
|
||||
.segmented-control-option.is-active:hover,
|
||||
.segmented-control-option.is-active:focus-visible,
|
||||
.segmented-control-option.active:hover,
|
||||
.segmented-control-option.active:focus-visible {
|
||||
background: var(--line);
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.14), inset 0 -1px 0 rgba(255,255,255,.45);
|
||||
}
|
||||
|
||||
.segmented-control-option:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.message-display-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -1533,35 +2312,6 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-display-body-switch {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.message-display-body-switch button {
|
||||
border: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 5px 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-display-body-switch button:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.message-display-body-switch button.active {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.message-display-body,
|
||||
.message-display-html-frame {
|
||||
width: 100%;
|
||||
@@ -1710,57 +2460,9 @@
|
||||
gap: 12px;
|
||||
}
|
||||
.mail-server-segmented-control {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
justify-self: center;
|
||||
width: min(420px, 100%);
|
||||
overflow: hidden;
|
||||
border: 1px solid #c9c3b9;
|
||||
border-radius: 8px;
|
||||
background: #c9c3b9;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
||||
}
|
||||
.mail-server-segmented-tab {
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(#ffffff, #f1efeb);
|
||||
color: #4f4a43;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
||||
transition: background .18s ease, box-shadow .18s ease, color .18s ease;
|
||||
}
|
||||
.mail-server-segmented-tab + .mail-server-segmented-tab {
|
||||
border-left: 1px solid #c9c3b9;
|
||||
}
|
||||
.mail-server-segmented-tab:first-child {
|
||||
border-radius: 7px 0 0 7px;
|
||||
}
|
||||
.mail-server-segmented-tab:last-child {
|
||||
border-radius: 0 7px 7px 0;
|
||||
}
|
||||
.mail-server-segmented-tab:hover,
|
||||
.mail-server-segmented-tab:focus-visible {
|
||||
background: linear-gradient(#ffffff, #e8e5df);
|
||||
color: #4f4a43;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
|
||||
}
|
||||
.mail-server-segmented-tab:focus-visible {
|
||||
outline: 3px solid rgba(82, 130, 177, .22);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.mail-server-segmented-tab.is-active {
|
||||
background: var(--line);
|
||||
color: var(--text-strong);
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.12), inset 0 -1px 0 rgba(255,255,255,.45);
|
||||
}
|
||||
.mail-server-segmented-tab.is-active:hover,
|
||||
.mail-server-segmented-tab.is-active:focus-visible {
|
||||
background: var(--line);
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.14), inset 0 -1px 0 rgba(255,255,255,.45);
|
||||
}
|
||||
.mail-server-settings-view {
|
||||
min-width: 0;
|
||||
|
||||
@@ -5,6 +5,34 @@
|
||||
.form-help { font-size: 12px; color: var(--muted); }
|
||||
input, select, textarea { border: 1px solid var(--line); border-radius: 5px; background: #fff; font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: inset 0 1px 2px rgba(0,0,0,.04); }
|
||||
textarea { resize: vertical; }
|
||||
.form-field:has(input:disabled, select:disabled, textarea:disabled, input[readonly], textarea[readonly]) .form-label { color: var(--control-disabled-label); }
|
||||
input:disabled:not([type="checkbox"]):not([type="radio"]),
|
||||
select:disabled,
|
||||
textarea:disabled,
|
||||
input[readonly]:not([type="checkbox"]):not([type="radio"]),
|
||||
textarea[readonly] {
|
||||
border-color: var(--control-disabled-border);
|
||||
background: var(--control-disabled-bg);
|
||||
color: var(--control-disabled-text);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.32);
|
||||
opacity: 1;
|
||||
}
|
||||
input:disabled:not([type="checkbox"]):not([type="radio"]),
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
input[readonly]:not(:disabled),
|
||||
textarea[readonly]:not(:disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
input:disabled::placeholder,
|
||||
textarea:disabled::placeholder,
|
||||
input[readonly]::placeholder,
|
||||
textarea[readonly]::placeholder {
|
||||
color: var(--control-disabled-placeholder);
|
||||
opacity: 1;
|
||||
}
|
||||
.btn { border: 1px solid var(--line-dark); border-radius: 4px; padding: 9px 14px; font: inherit; font-weight: 700; cursor: pointer; background: #f8f8f7; color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,.15); }
|
||||
.btn-primary { background: var(--green); border-color: #5aa99b; color: #fff; }
|
||||
.btn-ghost { border-color: transparent; background: transparent; box-shadow: none; }
|
||||
|
||||
@@ -14,12 +14,20 @@
|
||||
.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; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
|
||||
.titlebar { background: #fbfbfa; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: 0px 0px 10px 0px darkgrey; }
|
||||
.titlebar { position: relative; 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); }
|
||||
.titlebar-spacer { flex: 1; }
|
||||
.titlebar-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; }
|
||||
.account-pill { color: var(--text); }
|
||||
.maintenance-topbar-link { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border: 1px solid rgba(154, 92, 0, .26); background: #ffe1a3; color: #593700; border-radius: 6px; min-height: 32px; padding: 0 14px; font: inherit; font-weight: 800; box-shadow: 0 1px 2px rgba(0,0,0,.08); cursor: pointer; z-index: 1; }
|
||||
.maintenance-topbar-link:hover { background: #ffd170; color: #3e2800; }
|
||||
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
|
||||
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.language-menu { min-width: 210px; }
|
||||
.language-menu .dropdown-item { justify-content: flex-start; }
|
||||
.language-menu .dropdown-item svg { margin-left: auto; }
|
||||
.language-option-code { width: 34px; color: var(--muted); }
|
||||
.api-mini { display: flex; gap: 6px; }
|
||||
.api-mini input { width: 155px; height: 30px; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 0 8px; }
|
||||
.breadcrumb-bar { background: var(--bar); border-bottom: 1px solid var(--line-dark); display: flex; align-items: center; padding: 0 22px; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 90; }
|
||||
@@ -37,11 +45,32 @@
|
||||
.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%; min-height: 0; overflow: auto; }
|
||||
.ui-no-sticky-section-sidebars .app-content { overflow: auto; }
|
||||
.ui-no-sticky-section-sidebars .workspace { height: auto; min-height: 100%; }
|
||||
.ui-no-sticky-section-sidebars .section-sidebar {
|
||||
height: max-content;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.ui-no-sticky-section-sidebars .workspace-content { overflow: visible; }
|
||||
.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; }
|
||||
.module-entry-page { max-width: none; }
|
||||
.workspace-data-page .card { margin-bottom: 18px; }
|
||||
.page-heading.workspace-heading {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 75;
|
||||
background: var(--bg, #f8f7f4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: 0 8px 18px rgba(65, 60, 52, .08);
|
||||
margin: -28px -34px 22px;
|
||||
padding: 18px 34px 16px;
|
||||
}
|
||||
.workspace-heading .mono-small { margin-top: 8px; }
|
||||
.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); }
|
||||
@@ -129,9 +158,58 @@
|
||||
.empty-state { min-height: 220px; display: grid; place-items: center; text-align: center; padding: 32px; }
|
||||
.empty-state h2 { margin: 0; color: var(--text-strong); }
|
||||
.empty-state p { max-width: 520px; margin: 8px auto 18px; color: var(--muted); }
|
||||
.docs-workspace { grid-template-columns: 292px minmax(0, 1fr); background: var(--panel); }
|
||||
.docs-workspace-content { background: var(--panel); }
|
||||
.docs-outline { padding-top: 22px; }
|
||||
.docs-sidebar-header { padding: 0 16px 16px; }
|
||||
.docs-sidebar-header .section-title { padding: 0 6px 12px; }
|
||||
.docs-audience-toggle { width: 100%; }
|
||||
.docs-tree { display: grid; gap: 2px; padding: 0 10px 24px; }
|
||||
.docs-tree-node { min-width: 0; }
|
||||
.docs-tree-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: start; gap: 3px; }
|
||||
.docs-tree-toggle, .docs-tree-toggle-placeholder { width: 22px; height: 32px; display: inline-grid; place-items: center; flex: 0 0 auto; }
|
||||
.docs-tree-toggle { border: 0; border-radius: 4px; background: transparent; color: #686560; cursor: pointer; }
|
||||
.docs-tree-toggle:hover { background: rgba(255,255,255,.35); color: var(--text-strong); }
|
||||
.docs-tree-toggle-placeholder { opacity: 0; }
|
||||
.docs-tree-children { display: grid; gap: 2px; }
|
||||
.docs-tree-page { width: 100%; min-height: 32px; border: 0; border-radius: 4px; background: transparent; color: #686560; font: inherit; font-size: 13px; line-height: 1.25; text-align: left; cursor: pointer; padding: 7px 9px; overflow-wrap: anywhere; }
|
||||
.docs-tree-page:hover, .docs-tree-page.is-active { background: rgba(255,255,255,.42); color: var(--text-strong); }
|
||||
.docs-tree-page.is-active { box-shadow: inset 3px 0 var(--accent); font-weight: 700; }
|
||||
.docs-tree-empty { color: var(--muted); font-size: 12px; line-height: 1.35; padding: 8px 9px; }
|
||||
.docs-page { min-height: 100%; background: var(--panel); }
|
||||
.docs-content { width: min(1180px, 100%); margin: 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) 260px; align-items: start; gap: 32px; }
|
||||
.docs-page-main { min-width: 0; }
|
||||
.docs-page-outline-box { position: sticky; top: 24px; border: 1px solid var(--line); border-radius: 6px; background: #fbfaf8; padding: 16px; box-shadow: 0 1px 2px rgba(0,0,0,.05); }
|
||||
.docs-page-outline-box h2 { margin: 0 0 10px; color: var(--text-strong); font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.docs-page-outline-box nav { display: grid; gap: 4px; }
|
||||
.docs-page-outline-box a { border-radius: 4px; color: var(--muted); font-size: 13px; line-height: 1.3; padding: 5px 6px; text-decoration: none; }
|
||||
.docs-page-outline-box a:hover { background: var(--panel); color: var(--text-strong); }
|
||||
.docs-section { scroll-margin-top: 24px; padding-bottom: 34px; border-bottom: 1px solid var(--line); }
|
||||
.docs-section:last-child { border-bottom: 0; }
|
||||
.docs-section h2 { margin: 0 0 18px; color: var(--text-strong); font-size: 24px; font-weight: 650; }
|
||||
.docs-reference-block { scroll-margin-top: 24px; margin-top: 24px; }
|
||||
.docs-reference-block:first-child { margin-top: 0; }
|
||||
.docs-reference-block h3, .docs-topic h3 { margin: 0 0 8px; color: var(--text-strong); }
|
||||
.docs-topic-list { display: grid; gap: 28px; }
|
||||
.docs-topic { scroll-margin-top: 24px; }
|
||||
.docs-topic-page h2 { margin: 0 0 12px; color: var(--text-strong); font-size: 28px; font-weight: 650; line-height: 1.18; }
|
||||
.docs-topic-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.docs-topic-summary { margin: 0 0 10px; color: var(--text); font-weight: 600; }
|
||||
.docs-topic-body { margin: 0; color: var(--text); line-height: 1.7; white-space: pre-line; }
|
||||
.docs-topic-links { margin: 12px 0 0; }
|
||||
.docs-topic-details { display: grid; gap: 16px; margin-top: 18px; }
|
||||
.docs-detail-block { scroll-margin-top: 24px; }
|
||||
.docs-detail-block h4 { margin: 0 0 7px; color: var(--text-strong); font-size: 15px; }
|
||||
.docs-detail-block p, .docs-detail-block ul, .docs-detail-block ol { margin: 0; line-height: 1.65; }
|
||||
.docs-detail-block ul, .docs-detail-block ol { padding-left: 22px; }
|
||||
.docs-unavailable-reason { margin-top: 16px; border-left: 3px solid var(--amber); padding-left: 12px; }
|
||||
@media (max-width: 900px) {
|
||||
.page-heading.split { align-items: flex-start; flex-direction: column; }
|
||||
.summary-grid, .detail-list div { grid-template-columns: 1fr; }
|
||||
.docs-workspace { grid-template-columns: 1fr; }
|
||||
.docs-outline { display: none; }
|
||||
.docs-content { grid-template-columns: 1fr; }
|
||||
.docs-page-outline-box { display: none; }
|
||||
}
|
||||
|
||||
/* Side rail: settings lives as the bottom utility entry. */
|
||||
|
||||
@@ -16,18 +16,6 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.retention-policy-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.retention-policy-section h3 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
|
||||
.retention-policy-row {
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1fr);
|
||||
}
|
||||
|
||||
@@ -178,6 +178,20 @@
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card-body > .connection-tree:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card-body > .loading-frame:only-child > .connection-tree:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.data-grid-container {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -238,6 +252,15 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ui-compact-tables .data-grid-cell {
|
||||
padding: 7px 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ui-compact-tables .data-grid-header-cell {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.data-grid-cell:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
@@ -736,3 +759,124 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reusable two-level connection/credential tree. */
|
||||
.connection-tree {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.connection-tree-header,
|
||||
.connection-tree-row {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.connection-tree-header {
|
||||
min-height: 40px;
|
||||
border-bottom: 1px solid var(--line-dark);
|
||||
background: var(--bar);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.connection-tree-row {
|
||||
min-height: 58px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.connection-tree-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.connection-tree-row.is-child {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.connection-tree-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.connection-tree-cell:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.connection-tree-primary-cell {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.connection-tree-primary-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-left: calc(var(--connection-tree-depth, 0) * 22px);
|
||||
}
|
||||
|
||||
.connection-tree-cell.align-center {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connection-tree-cell.align-right {
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.connection-tree-cell > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.connection-tree-actions {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.connection-tree-empty {
|
||||
padding: 18px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.connection-tree-main {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.connection-tree-main strong,
|
||||
.connection-tree-main small,
|
||||
.connection-tree-muted-line {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connection-tree-main small,
|
||||
.connection-tree-muted-line {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.connection-tree {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.connection-tree-header,
|
||||
.connection-tree-row {
|
||||
min-width: 760px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
--text-strong: #303135;
|
||||
--muted: #8a8781;
|
||||
--muted-text: var(--muted);
|
||||
--control-disabled-bg: #ebe8e2;
|
||||
--control-disabled-border: #c2bcb3;
|
||||
--control-disabled-text: #706b63;
|
||||
--control-disabled-placeholder: #969087;
|
||||
--control-disabled-label: #7d776f;
|
||||
|
||||
--green: #7bbcaf;
|
||||
--amber: #f5c56c;
|
||||
@@ -45,6 +50,42 @@
|
||||
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--rail-bg: #171819;
|
||||
--rail-bg-active: #0f1011;
|
||||
--bg: #202120;
|
||||
--bar: #34342f;
|
||||
--panel: #2b2c2a;
|
||||
--panel-soft: #242523;
|
||||
--panel-header: #30312e;
|
||||
--surface: #262724;
|
||||
--surface-subtle: #2e302c;
|
||||
--line: #454740;
|
||||
--line-dark: #5b5d54;
|
||||
--border: var(--line);
|
||||
--text: #dfddd7;
|
||||
--text-strong: #f6f4ed;
|
||||
--muted: #aaa79d;
|
||||
--muted-text: var(--muted);
|
||||
--control-disabled-bg: #33352f;
|
||||
--control-disabled-border: #5f6058;
|
||||
--control-disabled-text: #b8b4aa;
|
||||
--control-disabled-placeholder: #918d83;
|
||||
--control-disabled-label: #aaa69b;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 10px 30px rgba(0,0,0,.18);
|
||||
--shadow-menu: 0 18px 44px rgba(0,0,0,.42);
|
||||
--shadow-popover: 0 8px 22px rgba(0,0,0,.36);
|
||||
}
|
||||
|
||||
.ui-reduce-motion *,
|
||||
.ui-reduce-motion *::before,
|
||||
.ui-reduce-motion *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; }
|
||||
body { margin: 0; overflow: hidden; font-family: var(--font); color: var(--text); background: var(--bg); }
|
||||
|
||||
@@ -6,6 +6,27 @@ export type ApiSettings = {
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
export type DeltaDeletedItem = {
|
||||
id: string;
|
||||
resource_type?: string | null;
|
||||
revision?: string | null;
|
||||
deleted_at?: string | null;
|
||||
};
|
||||
|
||||
export type DeltaCollectionResponse<TItem, TDeleted extends DeltaDeletedItem = DeltaDeletedItem> = {
|
||||
items: TItem[];
|
||||
deleted: TDeleted[];
|
||||
watermark?: string | null;
|
||||
has_more?: boolean;
|
||||
full?: boolean;
|
||||
};
|
||||
|
||||
export type DeltaQueryOptions = {
|
||||
since?: string | null;
|
||||
limit?: number;
|
||||
include_deleted?: boolean;
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
account_id: string;
|
||||
@@ -14,6 +35,19 @@ export type AuthUser = {
|
||||
tenant_display_name?: string | null;
|
||||
is_tenant_admin?: boolean;
|
||||
password_reset_required?: boolean;
|
||||
preferred_language?: string | null;
|
||||
enabled_language_codes?: string[];
|
||||
ui_preferences?: UserUiPreferences;
|
||||
};
|
||||
|
||||
export type UserUiTheme = "system" | "light" | "dark";
|
||||
|
||||
export type UserUiPreferences = {
|
||||
compact_tables: boolean;
|
||||
show_inline_help_hints: boolean;
|
||||
reduce_motion: boolean;
|
||||
sticky_section_sidebars: boolean;
|
||||
theme: UserUiTheme;
|
||||
};
|
||||
|
||||
export type AuthTenant = {
|
||||
@@ -22,6 +56,7 @@ export type AuthTenant = {
|
||||
name: string;
|
||||
is_active?: boolean;
|
||||
default_locale?: string;
|
||||
enabled_language_codes?: string[];
|
||||
};
|
||||
|
||||
export type AuthTenantMembership = AuthTenant & {
|
||||
@@ -43,6 +78,25 @@ export type AuthGroup = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type PrincipalContext = {
|
||||
account_id: string;
|
||||
membership_id?: string | null;
|
||||
tenant_id?: string | null;
|
||||
identity_id?: string | null;
|
||||
scopes: string[];
|
||||
group_ids: string[];
|
||||
role_ids?: string[];
|
||||
function_assignment_ids?: string[];
|
||||
delegation_ids?: string[];
|
||||
auth_method: "session" | "api_key" | "service_account";
|
||||
api_key_id?: string | null;
|
||||
session_id?: string | null;
|
||||
service_account_id?: string | null;
|
||||
acting_for_account_id?: string | null;
|
||||
email?: string | null;
|
||||
display_name?: string | null;
|
||||
};
|
||||
|
||||
export type AuthInfo = {
|
||||
user: AuthUser;
|
||||
// Backwards-compatible active tenant alias returned by older/newer APIs.
|
||||
@@ -52,6 +106,10 @@ export type AuthInfo = {
|
||||
scopes: string[];
|
||||
roles: AuthRole[];
|
||||
groups: AuthGroup[];
|
||||
principal?: PrincipalContext | null;
|
||||
available_languages?: Array<{ code: string; label: string; native_label?: string | null }>;
|
||||
enabled_language_codes?: string[];
|
||||
default_language?: string;
|
||||
};
|
||||
|
||||
export type LoginResponse = AuthInfo & {
|
||||
@@ -166,6 +224,10 @@ export type PlatformRouteContribution = {
|
||||
|
||||
export type PlatformUiCapabilities = Record<string, unknown>;
|
||||
|
||||
export type PlatformTranslationDictionary = Record<string, string>;
|
||||
|
||||
export type PlatformTranslations = Record<string, PlatformTranslationDictionary>;
|
||||
|
||||
export type PlatformWebModule = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -179,10 +241,42 @@ export type PlatformWebModule = {
|
||||
remoteAssetContractVersion?: string | null;
|
||||
navItems?: PlatformNavItem[];
|
||||
routes?: PlatformRouteContribution[];
|
||||
translations?: PlatformTranslations;
|
||||
uiCapabilities?: PlatformUiCapabilities;
|
||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||
};
|
||||
|
||||
export type DashboardWidgetSize = "small" | "medium" | "wide" | "full";
|
||||
|
||||
export type DashboardWidgetTone = "neutral" | "good" | "warning" | "danger" | "info";
|
||||
|
||||
export type DashboardWidgetRenderContext = PlatformRouteContext & {
|
||||
modules: PlatformWebModule[];
|
||||
widgetId: string;
|
||||
refreshKey: number;
|
||||
size: DashboardWidgetSize;
|
||||
};
|
||||
|
||||
export type DashboardWidgetContribution = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
moduleId?: string;
|
||||
category?: string;
|
||||
order?: number;
|
||||
defaultVisible?: boolean;
|
||||
defaultSize?: DashboardWidgetSize;
|
||||
supportedSizes?: DashboardWidgetSize[];
|
||||
refreshIntervalMs?: number;
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
render: (context: DashboardWidgetRenderContext) => ReactNode;
|
||||
};
|
||||
|
||||
export type DashboardWidgetsUiCapability = {
|
||||
widgets: DashboardWidgetContribution[];
|
||||
};
|
||||
|
||||
export type MailSecurity = "plain" | "tls" | "starttls";
|
||||
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||
|
||||
@@ -305,6 +399,28 @@ export type MailProfilesUiCapability = {
|
||||
validateMailPolicy?: MailPolicyValidationFn;
|
||||
};
|
||||
|
||||
export type FileConnectorScope = "system" | "tenant" | "user" | "group";
|
||||
|
||||
export type FileConnectorTargetOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary?: string | null;
|
||||
};
|
||||
|
||||
export type FileConnectorScopeManagerProps = {
|
||||
settings: ApiSettings;
|
||||
scopeType: FileConnectorScope;
|
||||
scopeId?: string | null;
|
||||
targetOptions?: FileConnectorTargetOption[];
|
||||
targetLabel?: string;
|
||||
title?: string;
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
export type FilesConnectorsUiCapability = {
|
||||
FileConnectorScopeManager: ComponentType<FileConnectorScopeManagerProps>;
|
||||
};
|
||||
|
||||
export type MailDevMailboxUiCapability = {
|
||||
enabled: boolean;
|
||||
label?: string;
|
||||
@@ -353,6 +469,14 @@ export type FilesFileSpace = {
|
||||
owner_type: FilesOwnerType;
|
||||
owner_id: string;
|
||||
description?: string | null;
|
||||
space_type?: "managed" | "connector";
|
||||
connector_space_id?: string | null;
|
||||
connector_profile_id?: string | null;
|
||||
provider?: string | null;
|
||||
library_id?: string | null;
|
||||
remote_path?: string | null;
|
||||
sync_mode?: string | null;
|
||||
read_only?: boolean;
|
||||
};
|
||||
|
||||
export type FilesFileShare = {
|
||||
@@ -396,8 +520,16 @@ export type FilesFileFolder = {
|
||||
};
|
||||
|
||||
export type FilesFileSpacesResponse = { spaces: FilesFileSpace[] };
|
||||
export type FilesFileListResponse = { files: FilesManagedFile[] };
|
||||
export type FilesFileFoldersResponse = { folders: FilesFileFolder[] };
|
||||
export type FilesFileListResponse = { files: FilesManagedFile[]; cursor?: string | null; next_cursor?: string | null };
|
||||
export type FilesFileFoldersResponse = { folders: FilesFileFolder[]; cursor?: string | null; next_cursor?: string | null };
|
||||
export type FilesFileDeltaResponse = {
|
||||
files: FilesManagedFile[];
|
||||
folders: FilesFileFolder[];
|
||||
deleted: DeltaDeletedItem[];
|
||||
watermark?: string | null;
|
||||
has_more?: boolean;
|
||||
full?: boolean;
|
||||
};
|
||||
export type FilesPatternResolveResponse = {
|
||||
patterns: { pattern: string; matches: FilesManagedFile[] }[];
|
||||
unmatched: FilesManagedFile[];
|
||||
@@ -448,8 +580,9 @@ export type FilesFileExplorerUiCapability = {
|
||||
FolderTree: ComponentType<FilesFolderTreeProps>;
|
||||
ManagedFileChooser?: ComponentType<FilesManagedFileChooserProps>;
|
||||
listFileSpaces?: (settings: ApiSettings) => Promise<FilesFileSpacesResponse>;
|
||||
listFiles?: (settings: ApiSettings, params?: { owner_type?: FilesOwnerType | string; owner_id?: string; campaign_id?: string; path_prefix?: string }) => Promise<FilesFileListResponse>;
|
||||
listFolders?: (settings: ApiSettings, params: { owner_type: FilesOwnerType; owner_id: string }) => Promise<FilesFileFoldersResponse>;
|
||||
listFiles?: (settings: ApiSettings, params?: { owner_type?: FilesOwnerType | string; owner_id?: string; campaign_id?: string; path_prefix?: string; page_size?: number; cursor?: string | null }) => Promise<FilesFileListResponse>;
|
||||
listFilesDelta?: (settings: ApiSettings, params?: { owner_type?: FilesOwnerType | string; owner_id?: string; campaign_id?: string; path_prefix?: string; since?: string | null; limit?: number }) => Promise<FilesFileDeltaResponse>;
|
||||
listFolders?: (settings: ApiSettings, params: { owner_type: FilesOwnerType; owner_id: string; page_size?: number; cursor?: string | null }) => Promise<FilesFileFoldersResponse>;
|
||||
resolveFilePatterns?: (
|
||||
settings: ApiSettings,
|
||||
payload: { patterns: string[]; owner_type?: FilesOwnerType; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }
|
||||
|
||||
32
webui/src/utils/delta.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { DeltaDeletedItem } from "../types";
|
||||
|
||||
export type DeltaRowMergeOptions<T> = {
|
||||
deletedResourceType?: string;
|
||||
sort?: (left: T, right: T) => number;
|
||||
};
|
||||
|
||||
export function deltaDeletedIds(deleted: DeltaDeletedItem[] | undefined, resourceType?: string): Set<string> {
|
||||
return new Set(
|
||||
(deleted ?? [])
|
||||
.filter((item) => !resourceType || !item.resource_type || item.resource_type === resourceType)
|
||||
.map((item) => item.id)
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeDeltaRows<T>(
|
||||
current: T[],
|
||||
changed: T[],
|
||||
deleted: DeltaDeletedItem[] | undefined,
|
||||
getKey: (item: T) => string,
|
||||
options: DeltaRowMergeOptions<T> = {}
|
||||
): T[] {
|
||||
const byId = new Map(current.map((item) => [getKey(item), item]));
|
||||
for (const item of changed) {
|
||||
byId.set(getKey(item), item);
|
||||
}
|
||||
for (const id of deltaDeletedIds(deleted, options.deletedResourceType)) {
|
||||
byId.delete(id);
|
||||
}
|
||||
const rows = Array.from(byId.values());
|
||||
return options.sort ? rows.sort(options.sort) : rows;
|
||||
}
|
||||
27
webui/src/utils/deltaHooks.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
export type DeltaWatermarkStore = {
|
||||
getDeltaWatermark: (key: string) => string | null;
|
||||
setDeltaWatermark: (key: string, watermark: string | null | undefined) => void;
|
||||
resetDeltaWatermark: (key?: string) => void;
|
||||
};
|
||||
|
||||
export function useDeltaWatermarks(): DeltaWatermarkStore {
|
||||
const watermarksRef = useRef<Record<string, string | null>>({});
|
||||
|
||||
const getDeltaWatermark = useCallback((key: string) => watermarksRef.current[key] ?? null, []);
|
||||
|
||||
const setDeltaWatermark = useCallback((key: string, watermark: string | null | undefined) => {
|
||||
watermarksRef.current[key] = watermark ?? null;
|
||||
}, []);
|
||||
|
||||
const resetDeltaWatermark = useCallback((key?: string) => {
|
||||
if (key) {
|
||||
delete watermarksRef.current[key];
|
||||
return;
|
||||
}
|
||||
watermarksRef.current = {};
|
||||
}, []);
|
||||
|
||||
return { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark };
|
||||
}
|
||||
@@ -1,85 +1,85 @@
|
||||
const FIELD_HELP_BY_LABEL: Record<string, string> = {
|
||||
"API base URL": "Optional backend URL. Leave empty to use the current origin and the configured Vite/backend proxy.",
|
||||
"Automation API key": "Fallback API key for scripted or non-login access. Interactive browser sessions should use login tokens instead.",
|
||||
"Email": "Email address used to identify the user account for login or address entry.",
|
||||
"Password": "Password for the selected account or mail server. Stored and transmitted according to the configured backend flow.",
|
||||
"Name": "Human-readable display name shown in lists, reports or outgoing address headers.",
|
||||
"Email address": "The mailbox address, for example office@example.org.",
|
||||
"i18n:govoplan-core.api_base_url.1358fba4": "i18n:govoplan-core.optional_backend_url_leave_empty_to_use_the_curr.52bd2bb4",
|
||||
"i18n:govoplan-core.automation_api_key.5d4e2e6e": "i18n:govoplan-core.fallback_api_key_for_scripted_or_non_login_acces.17baecf2",
|
||||
"i18n:govoplan-core.email.84add5b2": "i18n:govoplan-core.email_address_used_to_identify_the_user_account_.0af40171",
|
||||
"i18n:govoplan-core.password.8be3c943": "i18n:govoplan-core.password_for_the_selected_account_or_mail_server.d9681f16",
|
||||
"i18n:govoplan-core.name.709a2322": "i18n:govoplan-core.human_readable_display_name_shown_in_lists_repor.a86e80a0",
|
||||
"i18n:govoplan-core.email_address.c94d3175": "i18n:govoplan-core.the_mailbox_address_for_example_office_example_o.8e6c2449",
|
||||
|
||||
"Campaign ID": "Stable technical identifier used in JSON, reports, filenames and backend references.",
|
||||
"Campaign name": "Human-readable campaign name shown in lists, review screens and reports.",
|
||||
"Mode": "Campaign operating mode. Use draft while editing, test for trial runs and send when preparing the real campaign.",
|
||||
"Scenario": "High-level campaign pattern used by guided setup to prefill sensible defaults.",
|
||||
"Description": "Internal explanation of the campaign purpose. This is not sent to recipients.",
|
||||
"i18n:govoplan-core.campaign_id.4c4ed79e": "i18n:govoplan-core.stable_technical_identifier_used_in_json_reports.c1088ac6",
|
||||
"i18n:govoplan-core.campaign_name.aa5d0e72": "i18n:govoplan-core.human_readable_campaign_name_shown_in_lists_revi.9023c9a6",
|
||||
"i18n:govoplan-core.mode.a7b93d21": "i18n:govoplan-core.campaign_operating_mode_use_draft_while_editing_.a3a9e257",
|
||||
"i18n:govoplan-core.scenario.569aae5b": "i18n:govoplan-core.high_level_campaign_pattern_used_by_guided_setup.c5ec6652",
|
||||
"i18n:govoplan-core.description.55f8ebc8": "i18n:govoplan-core.internal_explanation_of_the_campaign_purpose_thi.f232e76f",
|
||||
|
||||
"Default From address": "Default sender address used for outgoing messages unless individual senders are allowed and set per recipient.",
|
||||
"From name": "Display name shown with the sender address in outgoing messages.",
|
||||
"From email": "Mailbox address used as the sender of outgoing messages.",
|
||||
"Global Reply-To address": "Default reply destination for recipient responses. Leave empty to let replies go to the From address.",
|
||||
"Reply-To": "Address recipients should reply to when it differs from the sender address.",
|
||||
"To": "Global To recipients added to every message in addition to recipient-specific recipients.",
|
||||
"Global recipients": "Default recipients included in every generated message.",
|
||||
"CC": "Global carbon-copy recipients included in every generated message.",
|
||||
"BCC": "Global blind-copy recipients included in every generated message.",
|
||||
"i18n:govoplan-core.default_from_address.b9ee6d77": "i18n:govoplan-core.default_sender_address_used_for_outgoing_message.3824700a",
|
||||
"i18n:govoplan-core.from_name.1e77633a": "i18n:govoplan-core.display_name_shown_with_the_sender_address_in_ou.21d950a3",
|
||||
"i18n:govoplan-core.from_email.1288a2a1": "i18n:govoplan-core.mailbox_address_used_as_the_sender_of_outgoing_m.c3c79e29",
|
||||
"i18n:govoplan-core.global_reply_to_address.bec1a89b": "i18n:govoplan-core.default_reply_destination_for_recipient_response.86474287",
|
||||
"i18n:govoplan-core.reply_to.c1733667": "i18n:govoplan-core.address_recipients_should_reply_to_when_it_diffe.f9035357",
|
||||
"i18n:govoplan-core.to.ae79ea1e": "i18n:govoplan-core.global_to_recipients_added_to_every_message_in_a.f4d3154f",
|
||||
"i18n:govoplan-core.global_recipients.d9aaa427": "i18n:govoplan-core.default_recipients_included_in_every_generated_m.f98da78f",
|
||||
"i18n:govoplan-core.cc.c5a976de": "i18n:govoplan-core.global_carbon_copy_recipients_included_in_every_.51b2b29e",
|
||||
"i18n:govoplan-core.bcc.4c0145a3": "i18n:govoplan-core.global_blind_copy_recipients_included_in_every_g.54f7daa6",
|
||||
|
||||
"Template source": "Where this campaign template comes from. Inline means it is stored directly in the campaign draft.",
|
||||
"Library template": "Reusable template record this campaign should refer to once the template backend is available.",
|
||||
"Subject": "Message subject. Placeholders can reference campaign/global/recipient fields.",
|
||||
"Body": "Template body content shown for review. Placeholders are checked against campaign fields.",
|
||||
"Plain text body": "Plain text version of the email body. It should remain readable without HTML rendering.",
|
||||
"HTML body": "Optional HTML version of the email body for richer formatting.",
|
||||
"i18n:govoplan-core.template_source.8e395b7a": "i18n:govoplan-core.where_this_campaign_template_comes_from_inline_m.de97578d",
|
||||
"i18n:govoplan-core.library_template.c62eb776": "i18n:govoplan-core.reusable_template_record_this_campaign_should_re.5896529b",
|
||||
"i18n:govoplan-core.subject.8d183dbd": "i18n:govoplan-core.message_subject_placeholders_can_reference_campa.0426f9a1",
|
||||
"i18n:govoplan-core.body.718a7e8a": "i18n:govoplan-core.template_body_content_shown_for_review_placehold.57454b52",
|
||||
"i18n:govoplan-core.plain_text_body.030a0da0": "i18n:govoplan-core.plain_text_version_of_the_email_body_it_should_r.fcccbb7c",
|
||||
"i18n:govoplan-core.html_body.77b5ba37": "i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97",
|
||||
|
||||
"Attachment base path": "Base folder used to resolve campaign attachment rules and relative file patterns.",
|
||||
"Campaign attachment base path": "Base folder used during campaign creation to resolve attachment files and patterns.",
|
||||
"Default missing behavior": "Default review behavior when an expected attachment cannot be found.",
|
||||
"Default ambiguous behavior": "Default review behavior when a file pattern matches more than one attachment.",
|
||||
"Base directory": "Folder below the campaign attachment base path where this rule starts looking for files.",
|
||||
"File filter": "Filename or pattern used to select matching files. Field placeholders can be introduced later.",
|
||||
"Include subdirectories": "Whether the rule should also search below nested folders.",
|
||||
"Allow multiple matches": "Whether more than one matching file may be attached for this rule.",
|
||||
"Missing behavior": "Action or review severity when this rule finds no matching file.",
|
||||
"Ambiguous behavior": "Action or review severity when this rule finds multiple possible matches.",
|
||||
"i18n:govoplan-core.attachment_base_path.66e9940b": "i18n:govoplan-core.base_folder_used_to_resolve_campaign_attachment_.1c5d7f57",
|
||||
"i18n:govoplan-core.campaign_attachment_base_path.84827619": "i18n:govoplan-core.base_folder_used_during_campaign_creation_to_res.7d7c1681",
|
||||
"i18n:govoplan-core.default_missing_behavior.ffbd9cd7": "i18n:govoplan-core.default_review_behavior_when_an_expected_attachm.e4bd7ea5",
|
||||
"i18n:govoplan-core.default_ambiguous_behavior.878b9345": "i18n:govoplan-core.default_review_behavior_when_a_file_pattern_matc.b6ee0c22",
|
||||
"i18n:govoplan-core.base_directory.31c0edd6": "i18n:govoplan-core.folder_below_the_campaign_attachment_base_path_w.04f81c33",
|
||||
"i18n:govoplan-core.file_filter.cf0c5b6f": "i18n:govoplan-core.filename_or_pattern_used_to_select_matching_file.1f76eaa4",
|
||||
"i18n:govoplan-core.include_subdirectories.97984dfb": "i18n:govoplan-core.whether_the_rule_should_also_search_below_nested.7c05e589",
|
||||
"i18n:govoplan-core.allow_multiple_matches.1ad3a854": "i18n:govoplan-core.whether_more_than_one_matching_file_may_be_attac.b29c38c1",
|
||||
"i18n:govoplan-core.missing_behavior.de3d1ac7": "i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_n.bc500ee1",
|
||||
"i18n:govoplan-core.ambiguous_behavior.e86e724b": "i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_m.2e6863d1",
|
||||
|
||||
"Host": "Mail server hostname or IP address for the selected protocol.",
|
||||
"SMTP host": "SMTP server hostname or IP address used for sending messages.",
|
||||
"Port": "Network port used by the selected mail protocol.",
|
||||
"SMTP port": "Network port used by the SMTP server, commonly 587 for STARTTLS or 465 for TLS.",
|
||||
"Username": "Login name for the selected mail server. Often the same as the mailbox address.",
|
||||
"Security": "Connection security mode expected by the server: plain, TLS or STARTTLS.",
|
||||
"Timeout seconds": "Maximum time to wait for the mail server before the connection test or send attempt fails.",
|
||||
"Detected/saved sent folder": "IMAP folder name used as the detected or manually configured Sent folder.",
|
||||
"Append folder": "Folder where successfully sent messages should be appended via IMAP.",
|
||||
"IMAP append to Sent": "Whether sent messages should also be copied into the configured Sent folder via IMAP.",
|
||||
"i18n:govoplan-core.host.3960ec4c": "i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69",
|
||||
"i18n:govoplan-core.smtp_host.2d4a434b": "i18n:govoplan-core.smtp_server_hostname_or_ip_address_used_for_send.47eabfa4",
|
||||
"i18n:govoplan-core.port.fe035157": "i18n:govoplan-core.network_port_used_by_the_selected_mail_protocol.664fa120",
|
||||
"i18n:govoplan-core.smtp_port.65b5a108": "i18n:govoplan-core.network_port_used_by_the_smtp_server_commonly_58.11ef582f",
|
||||
"i18n:govoplan-core.username.84c29015": "i18n:govoplan-core.login_name_for_the_selected_mail_server_often_th.5ae22850",
|
||||
"i18n:govoplan-core.security.f25ce1b8": "i18n:govoplan-core.connection_security_mode_expected_by_the_server_.4348e12e",
|
||||
"i18n:govoplan-core.timeout_seconds.0bfc6553": "i18n:govoplan-core.maximum_time_to_wait_for_the_mail_server_before_.4b0241d0",
|
||||
"i18n:govoplan-core.detected_saved_sent_folder.18642a29": "i18n:govoplan-core.imap_folder_name_used_as_the_detected_or_manuall.45ad006f",
|
||||
"i18n:govoplan-core.append_folder.06ed4bba": "i18n:govoplan-core.folder_where_successfully_sent_messages_should_b.538b4e3e",
|
||||
"i18n:govoplan-core.imap_append_to_sent.1a097d3a": "i18n:govoplan-core.whether_sent_messages_should_also_be_copied_into.7fe7e178",
|
||||
|
||||
"Messages per minute": "Rate limit for outgoing messages. Lower values are safer for mail providers and throttled accounts.",
|
||||
"Concurrency": "Number of send operations that may run in parallel. Keep low until account and provider limits are clear.",
|
||||
"Max attempts": "Maximum retry attempts for a message before it remains failed for review.",
|
||||
"i18n:govoplan-core.messages_per_minute.bea49348": "i18n:govoplan-core.rate_limit_for_outgoing_messages_lower_values_ar.9e929f6c",
|
||||
"i18n:govoplan-core.concurrency.2ec390bf": "i18n:govoplan-core.number_of_send_operations_that_may_run_in_parall.557a7155",
|
||||
"i18n:govoplan-core.max_attempts.f684fef4": "i18n:govoplan-core.maximum_retry_attempts_for_a_message_before_it_r.ad40b526",
|
||||
|
||||
"Source type": "Recipient data source format. Inline data is edited in the campaign; external sources will be parsed later.",
|
||||
"Source path": "Path or identifier for an external recipient source such as a CSV file.",
|
||||
"i18n:govoplan-core.source_type.5d02ee8d": "i18n:govoplan-core.recipient_data_source_format_inline_data_is_edit.086c01b0",
|
||||
"i18n:govoplan-core.source_path.5956aebe": "i18n:govoplan-core.path_or_identifier_for_an_external_recipient_sou.f7da6e8f",
|
||||
|
||||
"Remember previously used addresses": "Planned opt-in collection for autocomplete across campaigns. Currently autocomplete is campaign-local only.",
|
||||
"External address-book sync": "Placeholder for CardDAV, Google, LDAP or similar address sources.",
|
||||
"Allow individual senders": "Permit recipient rows to override the campaign's default sender address.",
|
||||
"Allow individual From": "Permit per-recipient sender overrides when building messages.",
|
||||
"Allow individual Reply-To": "Permit recipient rows to override the global Reply-To address.",
|
||||
"Allow individual To": "Permit recipient rows to define their own To recipients in addition to global headers.",
|
||||
"Allow individual CC": "Permit recipient rows to define their own CC recipients.",
|
||||
"Allow individual BCC": "Permit recipient rows to define their own BCC recipients.",
|
||||
"Allow individual attachments": "Permit recipient-specific attachment rules in addition to global files.",
|
||||
"Send without attachments": "Allow messages to be sent even when no attachment is resolved. Disable to force review or blocking.",
|
||||
"Status tracking": "Store per-message build, validation, queue, send and IMAP status for review and reporting.",
|
||||
"Suggest addresses from this campaign": "Use addresses already present in this campaign as autocomplete suggestions.",
|
||||
"Remember newly used addresses": "Prepare newly entered addresses for a future user-level address memory.",
|
||||
"Show guided warnings while editing": "Show inline guidance and warnings while campaign data is being edited.",
|
||||
"Required": "Mark this item as mandatory. Missing required data should become a validation or review issue.",
|
||||
"Subdirs": "Search nested folders below the configured base directory.",
|
||||
"Can override": "Allow recipient-specific values to override the global value for this field.",
|
||||
"Append successfully sent messages to Sent": "After SMTP send succeeds, append a copy of the message to the selected IMAP Sent folder.",
|
||||
"Append successful messages to Sent via IMAP": "After SMTP send succeeds, append a copy of the message to the configured IMAP Sent folder."
|
||||
"i18n:govoplan-core.remember_previously_used_addresses.0ae67018": "i18n:govoplan-core.planned_opt_in_collection_for_autocomplete_acros.2f06b032",
|
||||
"i18n:govoplan-core.external_address_book_sync.40412341": "i18n:govoplan-core.placeholder_for_carddav_google_ldap_or_similar_a.1a22f474",
|
||||
"i18n:govoplan-core.allow_individual_senders.f85fda12": "i18n:govoplan-core.permit_recipient_rows_to_override_the_campaign_s.ec2e6233",
|
||||
"i18n:govoplan-core.allow_individual_from.ca797b5c": "i18n:govoplan-core.permit_per_recipient_sender_overrides_when_build.62e46d13",
|
||||
"i18n:govoplan-core.allow_individual_reply_to.028f8044": "i18n:govoplan-core.permit_recipient_rows_to_override_the_global_rep.092d7e85",
|
||||
"i18n:govoplan-core.allow_individual_to.966cb33e": "i18n:govoplan-core.permit_recipient_rows_to_define_their_own_to_rec.3b37defe",
|
||||
"i18n:govoplan-core.allow_individual_cc.0457c0e2": "i18n:govoplan-core.permit_recipient_rows_to_define_their_own_cc_rec.0247b3cd",
|
||||
"i18n:govoplan-core.allow_individual_bcc.a932d94b": "i18n:govoplan-core.permit_recipient_rows_to_define_their_own_bcc_re.46d737dc",
|
||||
"i18n:govoplan-core.allow_individual_attachments.a6ff0e87": "i18n:govoplan-core.permit_recipient_specific_attachment_rules_in_ad.7bbe1f71",
|
||||
"i18n:govoplan-core.send_without_attachments.ead6d030": "i18n:govoplan-core.allow_messages_to_be_sent_even_when_no_attachmen.5c5268e1",
|
||||
"i18n:govoplan-core.status_tracking.15f61f88": "i18n:govoplan-core.store_per_message_build_validation_queue_send_an.57e387c9",
|
||||
"i18n:govoplan-core.suggest_addresses_from_this_campaign.5ebe2aea": "i18n:govoplan-core.use_addresses_already_present_in_this_campaign_a.43258b88",
|
||||
"i18n:govoplan-core.remember_newly_used_addresses.aebe8adb": "i18n:govoplan-core.prepare_newly_entered_addresses_for_a_future_use.b77195ed",
|
||||
"i18n:govoplan-core.show_guided_warnings_while_editing.bc5dba85": "i18n:govoplan-core.show_inline_guidance_and_warnings_while_campaign.a892f5e9",
|
||||
"i18n:govoplan-core.required.eed6bfb4": "i18n:govoplan-core.mark_this_item_as_mandatory_missing_required_dat.3a6f40c8",
|
||||
"i18n:govoplan-core.subdirs.ab9017e1": "i18n:govoplan-core.search_nested_folders_below_the_configured_base_.122d1916",
|
||||
"i18n:govoplan-core.can_override.c37b61c4": "i18n:govoplan-core.allow_recipient_specific_values_to_override_the_.1b21d0f0",
|
||||
"i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e": "i18n:govoplan-core.after_smtp_send_succeeds_append_a_copy_of_the_me.2842ed88",
|
||||
"i18n:govoplan-core.append_successful_messages_to_sent_via_imap.dbd1b1d8": "i18n:govoplan-core.after_smtp_send_succeeds_append_a_copy_of_the_me.686e7abb"
|
||||
};
|
||||
|
||||
export function helpForFieldLabel(label: unknown): string | undefined {
|
||||
if (typeof label !== "string") return undefined;
|
||||
return FIELD_HELP_BY_LABEL[label];
|
||||
}
|
||||
}
|
||||
@@ -5,36 +5,36 @@ export type HelpContext = {
|
||||
};
|
||||
|
||||
const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
|
||||
data: { id: "campaign.settings", title: "Campaign settings" },
|
||||
campaign: { id: "campaign.settings", title: "Campaign settings" },
|
||||
fields: { id: "campaign.fields", title: "Campaign fields" },
|
||||
template: { id: "campaign.template", title: "Template" },
|
||||
files: { id: "campaign.attachments", title: "Attachments" },
|
||||
attachments: { id: "campaign.attachments", title: "Attachments" },
|
||||
recipients: { id: "campaign.recipients", title: "Sender & Recipients" },
|
||||
"recipient-data": { id: "campaign.recipient-data", title: "Recipient data" },
|
||||
"mail-settings": { id: "campaign.server-settings", title: "Server settings" },
|
||||
"server-settings": { id: "campaign.server-settings", title: "Server settings" },
|
||||
mail: { id: "campaign.server-settings", title: "Server settings" },
|
||||
"global-settings": { id: "campaign.global-settings", title: "Policies" },
|
||||
settings: { id: "campaign.global-settings", title: "Policies" },
|
||||
review: { id: "campaign.review-send", title: "Review & Send" },
|
||||
send: { id: "campaign.review-send", title: "Review & Send" },
|
||||
report: { id: "campaign.report", title: "Report" },
|
||||
reports: { id: "campaign.report", title: "Report" },
|
||||
audit: { id: "campaign.audit", title: "Audit log" },
|
||||
json: { id: "campaign.json", title: "JSON" }
|
||||
data: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26" },
|
||||
campaign: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26" },
|
||||
fields: { id: "campaign.fields", title: "i18n:govoplan-core.campaign_fields.969e7d80" },
|
||||
template: { id: "campaign.template", title: "i18n:govoplan-core.template.3ec1ae06" },
|
||||
files: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6" },
|
||||
attachments: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6" },
|
||||
recipients: { id: "campaign.recipients", title: "i18n:govoplan-core.sender_recipients.922c6d24" },
|
||||
"recipient-data": { id: "campaign.recipient-data", title: "i18n:govoplan-core.recipient_data.c2baaf10" },
|
||||
"mail-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
|
||||
"server-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
|
||||
mail: { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
|
||||
"global-settings": { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849" },
|
||||
settings: { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849" },
|
||||
review: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d" },
|
||||
send: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d" },
|
||||
report: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303" },
|
||||
reports: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303" },
|
||||
audit: { id: "campaign.audit", title: "i18n:govoplan-core.audit_log.3cfc5f1c" },
|
||||
json: { id: "campaign.json", title: "i18n:govoplan-core.json.031a4e76" }
|
||||
};
|
||||
|
||||
const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
|
||||
dashboard: { id: "app.dashboard", title: "Dashboard" },
|
||||
campaigns: { id: "campaigns.list", title: "Campaigns" },
|
||||
templates: { id: "templates.list", title: "Templates" },
|
||||
files: { id: "files.list", title: "Files" },
|
||||
"address-book": { id: "address-book.list", title: "Address Book" },
|
||||
reports: { id: "reports.list", title: "Reports" },
|
||||
settings: { id: "app.settings", title: "Settings" },
|
||||
admin: { id: "app.admin", title: "Admin" }
|
||||
dashboard: { id: "app.dashboard", title: "i18n:govoplan-core.dashboard.d87f47b4" },
|
||||
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28" },
|
||||
templates: { id: "templates.list", title: "i18n:govoplan-core.templates.f25b700e" },
|
||||
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512" },
|
||||
"address-book": { id: "address-book.list", title: "i18n:govoplan-core.address_book.f6327f59" },
|
||||
reports: { id: "reports.list", title: "i18n:govoplan-core.reports.88bc3fe3" },
|
||||
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5" },
|
||||
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc" }
|
||||
};
|
||||
|
||||
export function helpContextForPathname(pathname: string): HelpContext {
|
||||
@@ -42,19 +42,19 @@ export function helpContextForPathname(pathname: string): HelpContext {
|
||||
const segments = route.split("/").filter(Boolean);
|
||||
|
||||
if (segments[0] === "campaigns" && segments[1]) {
|
||||
if (!segments[2]) return { id: "campaign.overview", title: "Campaign overview", route };
|
||||
if (!segments[2]) return { id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", route };
|
||||
if (segments[2] === "wizard") {
|
||||
const step = segments[3] || "create";
|
||||
return { id: `campaign.wizard.${step}`, title: `${capitalize(step)} wizard`, route };
|
||||
}
|
||||
const context = campaignSectionContexts[segments[2]];
|
||||
if (context) return { ...context, route };
|
||||
return { id: "campaign.workspace", title: "Campaign workspace", route };
|
||||
return { id: "campaign.workspace", title: "i18n:govoplan-core.campaign_workspace.c345580f", route };
|
||||
}
|
||||
|
||||
const context = topLevelContexts[segments[0] || "campaigns"];
|
||||
if (context) return { ...context, route };
|
||||
return { id: "app.general", title: "Application", route };
|
||||
return { id: "app.general", title: "i18n:govoplan-core.application.b291beb8", route };
|
||||
}
|
||||
|
||||
export function helpQueryForContext(context: HelpContext): string {
|
||||
@@ -63,4 +63,4 @@ export function helpQueryForContext(context: HelpContext): string {
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ assert(!savedPassword.includes("password-field-toggle"), "saved empty password d
|
||||
const typedPassword = renderToStaticMarkup(
|
||||
<PasswordField value="secret" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
|
||||
);
|
||||
assert(typedPassword.includes('aria-label="Show password"'), "typed password shows reveal button");
|
||||
assert(typedPassword.includes('aria-label="i18n:govoplan-core.show_password.044b852f"'), "typed password shows reveal button");
|
||||
assert(typedPassword.includes("password-field-toggle"), "typed password gets toggle class");
|
||||
|
||||
const settingsPanel = renderToStaticMarkup(
|
||||
@@ -54,9 +54,9 @@ const settingsPanel = renderToStaticMarkup(
|
||||
onUseDetectedFolder={noop}
|
||||
/>
|
||||
);
|
||||
assert(settingsPanel.includes("Test SMTP"), "SMTP test action is rendered");
|
||||
assert(settingsPanel.includes("Server"), "server heading is rendered");
|
||||
assert(settingsPanel.includes("Credentials"), "credentials heading is rendered");
|
||||
assert(settingsPanel.includes("i18n:govoplan-core.test_smtp.e5697981"), "SMTP test action is rendered");
|
||||
assert(settingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "server heading is rendered");
|
||||
assert(settingsPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "credentials heading is rendered");
|
||||
assert(!settingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered");
|
||||
assert(settingsPanel.includes('placeholder="Saved SMTP password"'), "SMTP saved credential placeholder is rendered");
|
||||
|
||||
@@ -81,11 +81,11 @@ const imapSettingsPanel = renderToStaticMarkup(
|
||||
onUseDetectedFolder={noop}
|
||||
/>
|
||||
);
|
||||
assert(imapSettingsPanel.includes("Test IMAP"), "IMAP test action is rendered");
|
||||
assert(imapSettingsPanel.includes("Server"), "IMAP server heading is rendered");
|
||||
assert(imapSettingsPanel.includes("Credentials"), "IMAP credentials heading is rendered");
|
||||
assert(imapSettingsPanel.includes("i18n:govoplan-core.test_imap.ef1bd79c"), "IMAP test action is rendered");
|
||||
assert(imapSettingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "IMAP server heading is rendered");
|
||||
assert(imapSettingsPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "IMAP credentials heading is rendered");
|
||||
assert(!imapSettingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered in IMAP tab");
|
||||
assert(!imapSettingsPanel.includes("Folders..."), "folder lookup action is not rendered in the IMAP tab");
|
||||
assert(!imapSettingsPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is not rendered in the IMAP tab");
|
||||
assert(imapSettingsPanel.includes('placeholder="Saved IMAP password"'), "IMAP saved credential placeholder is rendered");
|
||||
assert(!imapSettingsPanel.includes("Folders loaded"), "folder lookup result is not rendered in the IMAP tab");
|
||||
assert(!imapSettingsPanel.includes("Default sent folder"), "default sent folder field is not rendered");
|
||||
@@ -108,10 +108,10 @@ const advancedSettingsPanel = renderToStaticMarkup(
|
||||
onUseDetectedFolder={noop}
|
||||
/>
|
||||
);
|
||||
assert(advancedSettingsPanel.includes("Append target folder"), "append target folder is the advanced IMAP folder field");
|
||||
assert(advancedSettingsPanel.includes("Folders..."), "folder lookup action is rendered near append target folder");
|
||||
assert(advancedSettingsPanel.includes("i18n:govoplan-core.append_target_folder.0aaacc0c"), "append target folder is the advanced IMAP folder field");
|
||||
assert(advancedSettingsPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is rendered near append target folder");
|
||||
assert(advancedSettingsPanel.includes("Folders loaded"), "folder lookup result is rendered in Advanced");
|
||||
assert(advancedSettingsPanel.includes("Use detected folder"), "detected folder action is rendered near append target folder");
|
||||
assert(advancedSettingsPanel.includes("i18n:govoplan-core.use_detected_folder.5ec4965c"), "detected folder action is rendered near append target folder");
|
||||
assert(advancedSettingsPanel.includes('value="Sent"'), "append target folder uses IMAP sent folder value without append override");
|
||||
|
||||
const disabledAppendFolderPanel = renderToStaticMarkup(
|
||||
@@ -139,8 +139,8 @@ const disabledAppendFolderPanel = renderToStaticMarkup(
|
||||
}}
|
||||
/>
|
||||
);
|
||||
assert(!disabledAppendFolderPanel.includes("Folders..."), "folder lookup action is hidden when append target is disabled");
|
||||
assert(!disabledAppendFolderPanel.includes("Use detected folder"), "detected folder action is hidden when append target is disabled");
|
||||
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is hidden when append target is disabled");
|
||||
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.use_detected_folder.5ec4965c"), "detected folder action is hidden when append target is disabled");
|
||||
|
||||
const defaultPortSettingsPanel = renderToStaticMarkup(
|
||||
<MailServerSettingsPanel
|
||||
@@ -190,9 +190,9 @@ assert(messageDisplay.includes("Quarterly update"), "message subject is rendered
|
||||
assert(messageDisplay.includes("Sender <sender@example.org>"), "sender field is rendered");
|
||||
assert(!messageDisplay.includes("<dt>Cc</dt>"), "empty optional CC field is hidden");
|
||||
assert(messageDisplay.includes("<dt>Bcc</dt>"), "present BCC field is rendered");
|
||||
assert(messageDisplay.includes("HTML"), "HTML body switch is rendered");
|
||||
assert(messageDisplay.includes("Text"), "text body switch is rendered");
|
||||
assert(messageDisplay.includes("i18n:govoplan-core.html.9f738ce8"), "HTML body switch is rendered");
|
||||
assert(messageDisplay.includes("i18n:govoplan-core.text.c3328c39"), "text body switch is rendered");
|
||||
assert(messageDisplay.includes("agenda.pdf"), "direct attachment is rendered");
|
||||
assert(messageDisplay.includes("recipient-files.zip"), "ZIP archive attachment group is rendered");
|
||||
assert(messageDisplay.includes("Password protected"), "ZIP protection badge is rendered");
|
||||
assert(messageDisplay.includes("i18n:govoplan-core.password_protected.09d9e174"), "ZIP protection badge is rendered");
|
||||
assert(messageDisplay.includes("zip_password"), "ZIP protection note is rendered");
|
||||
|
||||
34
webui/tests/privacy-policy.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
type PrivacyRetentionPolicyFieldKey,
|
||||
type RetentionPolicyLimitCarrier,
|
||||
privacyRetentionLocalAllowsLowerLevel,
|
||||
privacyRetentionParentAllowsField
|
||||
} from "../src/features/privacy/policyLogic";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const field: PrivacyRetentionPolicyFieldKey = "raw_campaign_json_retention_days";
|
||||
|
||||
const parentPolicy: RetentionPolicyLimitCarrier = {
|
||||
allow_lower_level_limits: {
|
||||
store_raw_campaign_json: true,
|
||||
raw_campaign_json_retention_days: false,
|
||||
generated_eml_retention_days: true,
|
||||
stored_report_detail_retention_days: true,
|
||||
mock_mailbox_retention_days: true,
|
||||
audit_detail_retention_days: true,
|
||||
audit_detail_level: true
|
||||
}
|
||||
};
|
||||
|
||||
const localPatch: RetentionPolicyLimitCarrier = {
|
||||
allow_lower_level_limits: { raw_campaign_json_retention_days: true }
|
||||
};
|
||||
|
||||
assert(!privacyRetentionParentAllowsField(parentPolicy, field), "parent lock should disable the local field control");
|
||||
assert(!privacyRetentionLocalAllowsLowerLevel(localPatch, parentPolicy, field, false), "local allow=true must not override a parent lock");
|
||||
assert(privacyRetentionParentAllowsField(null, field), "missing parent policy should allow local field controls");
|
||||
assert(privacyRetentionLocalAllowsLowerLevel({}, null, field, false), "unlocked inherited lower-level controls should remain enabled");
|
||||
assert(!privacyRetentionLocalAllowsLowerLevel({ allow_lower_level_limits: { [field]: false } }, null, field, true), "system policy can disable lower-level choices");
|
||||
@@ -16,7 +16,9 @@
|
||||
},
|
||||
"include": [
|
||||
"tests/module-capabilities.test.ts",
|
||||
"tests/privacy-policy.test.ts",
|
||||
"src/platform/moduleLogic.ts",
|
||||
"src/features/privacy/policyLogic.ts",
|
||||
"src/types.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,8 +15,11 @@ const defaultWebModulePackages = [
|
||||
"@govoplan/admin-webui",
|
||||
"@govoplan/calendar-webui",
|
||||
"@govoplan/campaign-webui",
|
||||
"@govoplan/dashboard-webui",
|
||||
"@govoplan/docs-webui",
|
||||
"@govoplan/files-webui",
|
||||
"@govoplan/mail-webui"
|
||||
"@govoplan/mail-webui",
|
||||
"@govoplan/ops-webui"
|
||||
];
|
||||
|
||||
function configuredWebModulePackages(): string[] {
|
||||
@@ -68,6 +71,11 @@ export default defineConfig({
|
||||
optimizeDeps: {
|
||||
exclude: availableWebModuleSpecifiers()
|
||||
},
|
||||
build: {
|
||||
// Full-product builds include the host shell plus all installed module wiring.
|
||||
// Dedicated route/vendor splitting belongs in a separate performance pass.
|
||||
chunkSizeWarningLimit: 1200
|
||||
},
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
dedupe: ["react", "react-dom", "react-router-dom"],
|
||||
@@ -83,9 +91,12 @@ export default defineConfig({
|
||||
fileURLToPath(new URL('../../govoplan-access/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-admin/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-calendar/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url))
|
||||
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url))
|
||||
]
|
||||
},
|
||||
proxy: {
|
||||
|
||||