chore: consolidate platform split checks
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled

This commit is contained in:
2026-07-10 12:51:19 +02:00
parent 150b720f12
commit 635d25c74c
216 changed files with 23336 additions and 4077 deletions

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
webui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

10
webui/public/favicon.svg Normal file
View 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

View 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

View 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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View 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.");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(", ") : "—";
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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 &lt;sender@example.org&gt;"), "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");

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

View File

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

View File

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