feat(webui): expose stable product destinations
Module Package Release / publish-packages (push) Successful in 13s

This commit is contained in:
2026-08-24 18:06:52 +02:00
parent 9a3008002d
commit ac40774785
27 changed files with 532 additions and 68 deletions
+17
View File
@@ -1062,6 +1062,23 @@ available owner route. It emits `govoplan:product-surface-route-resolved` before
the redirect so migration telemetry can observe alias use without making the the redirect so migration telemetry can observe alias use without making the
technical module part of the ordinary label. technical module part of the ordinary label.
The shell projects every authorized, View-visible owner route with a product
contribution into one stable product navigation item. The product label and
entry path replace package topology in the primary rail; every contributing
owner path still marks that item active. `All available tools` is a collapsed,
permission-derived catalogue built independently of the active View, so a
focused workflow cannot remove the explicit escape. It may reveal an
authorized owner route that a View omitted, but never an unauthorized route.
Navigation visibility preferences do not delete catalogue entries, and the
original owner routes remain compatible deep links.
The initial promoted destinations are `work.items` at `/work`,
`meetings.calendar` at `/agenda`, `communication.messages` at `/messages`
(with `/inbox` as an alias), and `records.files` at `/documents`. Their labels
and availability language are centralized in Core while Tasks, Calendar,
Mail/Postbox, and Files retain route, command, search, help, documentation,
authorization, and data ownership.
Use `ProductAvailabilityState` for unavailable and degraded outcomes. The Use `ProductAvailabilityState` for unavailable and degraded outcomes. The
ordinary state explains the attempted outcome, consequence, recovery path and ordinary state explains the attempted outcome, consequence, recovery path and
responsible role. Exact module, capability, provider and correlation values may responsible role. Exact module, capability, provider and correlation values may
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-core" name = "govoplan-core"
version = "0.1.43" version = "0.1.44"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"initialJs": { "initialJs": {
"rawBytes": 524288, "rawBytes": 524288,
"gzipBytes": 163840 "gzipBytes": 164128
}, },
"asyncChunk": { "asyncChunk": {
"rawBytes": 393216, "rawBytes": 393216,
+166 -2
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react"; import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage"; import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage"; import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
@@ -30,12 +30,23 @@ import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar"; import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import BreadcrumbBar from "../src/layout/BreadcrumbBar"; import BreadcrumbBar from "../src/layout/BreadcrumbBar";
import HelpMenu from "../src/layout/HelpMenu"; import HelpMenu from "../src/layout/HelpMenu";
import IconRail from "../src/layout/IconRail";
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard"; import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
import { import {
createQuickAccessLaunchContext, createQuickAccessLaunchContext,
quickAccessLaunchState quickAccessLaunchState
} from "../src/platform/launchContext"; } from "../src/platform/launchContext";
import type { ApiSettings, AuthInfo, EffectiveViewProjection, QuickAccessToolMetadata } from "../src/types"; import { projectProductNavigation } from "../src/platform/productSurfaces";
import type {
ApiSettings,
AuthInfo,
EffectiveViewProjection,
PlatformNavItem,
PlatformWebModule,
ProductAreaContribution,
ProductSurfaceContribution,
QuickAccessToolMetadata
} from "../src/types";
export default function ConformanceApp() { export default function ConformanceApp() {
const location = useLocation(); const location = useLocation();
@@ -43,6 +54,10 @@ export default function ConformanceApp() {
const [editorDirty, setEditorDirty] = useState(true); const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState(""); const [metricDrilldown, setMetricDrilldown] = useState("");
if (new URLSearchParams(location.search).has("product-navigation")) {
return <ProductNavigationScenario />;
}
if (location.pathname.startsWith("/forms/public/")) { if (location.pathname.startsWith("/forms/public/")) {
return <PublicFormPage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />; return <PublicFormPage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
} }
@@ -185,6 +200,39 @@ export default function ConformanceApp() {
); );
} }
function ProductNavigationScenario() {
const projection = useMemo(
() => projectProductNavigation(
PRODUCT_NAV_ITEMS,
PRODUCT_NAV_MODULES,
PRODUCT_NAV_AUTH
),
[]
);
return (
<div className="app-shell" data-conformance-id="product-navigation">
<IconRail
navItems={projection.primaryItems}
allToolItems={projection.allToolItems}
productAreas={PRODUCT_NAV_AREAS}
/>
<main className="main-area">
<PageLayout
archetype="overview"
mode="embedded"
title="Anwohnerparkausweis bearbeiten"
description="Die Navigation beschreibt Arbeit und Ergebnisse; technische Eigentümer bleiben nachvollziehbar erreichbar."
>
<StatePanel
title="Vorgang ist bereit"
description="Nutzen Sie Arbeit, Kalender, Nachrichten oder Dateien für den nächsten Schritt."
/>
</PageLayout>
</main>
</div>
);
}
function HelpConformanceScenario() { function HelpConformanceScenario() {
return ( return (
<section className="conformance-section" aria-labelledby="help-heading"> <section className="conformance-section" aria-labelledby="help-heading">
@@ -303,6 +351,122 @@ const CONFORMANCE_AUTH = {
groups_loaded: true groups_loaded: true
} satisfies AuthInfo; } satisfies AuthInfo;
const PRODUCT_NAV_AUTH = {
...CONFORMANCE_AUTH,
scopes: [
"tasks:item:read",
"calendar:event:read",
"mail:mailbox:read",
"postbox:message:read",
"files:file:read"
]
} satisfies AuthInfo;
const PRODUCT_NAV_ITEMS: PlatformNavItem[] = [
{ to: "/tasks", label: "Tasks", icon: ListChecks, surfaceId: "tasks.nav.tasks", anyOf: ["tasks:item:read"], order: 30 },
{ to: "/files", label: "Files", icon: Folder, surfaceId: "files.nav.files", anyOf: ["files:file:read"], order: 40 },
{ to: "/mail", label: "Mail", icon: Mail, surfaceId: "mail.nav.mail", anyOf: ["mail:mailbox:read"], order: 50 },
{ to: "/postbox", label: "Postbox", icon: Inbox, surfaceId: "postbox.nav.postbox", anyOf: ["postbox:message:read"], order: 51 },
{ to: "/calendar", label: "Calendar", icon: CalendarDays, surfaceId: "calendar.nav.calendar", anyOf: ["calendar:event:read"], order: 55 }
];
const PRODUCT_NAV_AREAS: ProductAreaContribution[] = [
{ id: "work", moduleId: "tasks", label: "i18n:govoplan-core.product_area.work", iconName: "list-checks", surfaceIds: ["tasks.nav.tasks"], order: 10 },
{ id: "records-documents", moduleId: "files", label: "i18n:govoplan-core.product_area.records_documents", iconName: "folder", surfaceIds: ["files.nav.files"], order: 30 },
{ id: "communication", moduleId: "mail", label: "i18n:govoplan-core.product_area.communication", iconName: "mail", surfaceIds: ["mail.nav.mail", "postbox.nav.postbox"], order: 40 },
{ id: "meetings-decisions", moduleId: "calendar", label: "i18n:govoplan-core.product_area.meetings_decisions", iconName: "calendar", surfaceIds: ["calendar.nav.calendar"], order: 50 }
];
const PRODUCT_NAV_MODULES: PlatformWebModule[] = [
productModule("tasks", productSurface({
id: "work.items",
moduleId: "tasks",
label: "i18n:govoplan-core.product_surface.work",
description: "i18n:govoplan-core.product_surface.work_description",
iconName: "list-checks",
entryPath: "/work",
routePath: "/tasks",
surfaceIds: ["tasks.nav.tasks"],
anyOf: ["tasks:item:read"]
})),
productModule("files", productSurface({
id: "records.files",
moduleId: "files",
label: "i18n:govoplan-core.product_surface.files",
description: "i18n:govoplan-core.product_surface.files_description",
iconName: "folder",
entryPath: "/documents",
routePath: "/files",
surfaceIds: ["files.nav.files"],
anyOf: ["files:file:read"]
})),
productModule("mail", productSurface({
id: "communication.messages",
moduleId: "mail",
label: "i18n:govoplan-core.product_surface.messages",
description: "i18n:govoplan-core.product_surface.messages_description",
iconName: "mail",
entryPath: "/messages",
routePath: "/mail",
surfaceIds: ["mail.nav.mail"],
anyOf: ["mail:mailbox:read"],
aliases: ["/inbox"]
})),
productModule("postbox", productSurface({
id: "communication.messages",
moduleId: "postbox",
label: "i18n:govoplan-core.product_surface.messages",
description: "i18n:govoplan-core.product_surface.messages_description",
iconName: "mail",
entryPath: "/messages",
routePath: "/postbox",
surfaceIds: ["postbox.nav.postbox"],
anyOf: ["postbox:message:read"],
aliases: ["/inbox"],
order: 20
})),
productModule("calendar", productSurface({
id: "meetings.calendar",
moduleId: "calendar",
label: "i18n:govoplan-core.product_surface.calendar",
description: "i18n:govoplan-core.product_surface.calendar_description",
iconName: "calendar",
entryPath: "/agenda",
routePath: "/calendar",
surfaceIds: ["calendar.nav.calendar"],
anyOf: ["calendar:event:read"]
}))
];
function productModule(id: string, surface: ProductSurfaceContribution): PlatformWebModule {
return { id, label: id, version: "test", productSurfaces: [surface] };
}
function productSurface(
partial: Pick<ProductSurfaceContribution,
"id" | "moduleId" | "label" | "description" | "iconName" | "entryPath" |
"routePath" | "surfaceIds" | "anyOf"> & Partial<ProductSurfaceContribution>
): ProductSurfaceContribution {
return {
contractVersion: "1",
presentations: ["task", "reader"],
capabilityIds: [],
searchSourceIds: [],
helpContextIds: [],
documentationTopicIds: [],
allOf: [],
aliases: [],
order: 10,
unavailable: {
reason: "authorization",
title: "Not available",
description: "The destination is not available for this responsibility.",
resolution: "Ask the access administrator to review the assignment."
},
...partial
};
}
const FORMS_RUNTIME_AUTH = { const FORMS_RUNTIME_AUTH = {
...CONFORMANCE_AUTH, ...CONFORMANCE_AUTH,
scopes: [ scopes: [
+2 -1
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router"; import { BrowserRouter, Route, Routes } from "react-router";
import ConformanceApp from "./ConformanceApp"; import ConformanceApp from "./ConformanceApp";
import { generatedTranslations as formsRuntimeTranslations } from "../../../govoplan-forms-runtime/webui/src/i18n/generatedTranslations"; import { generatedTranslations as formsRuntimeTranslations } from "../../../govoplan-forms-runtime/webui/src/i18n/generatedTranslations";
import { productSurfaceTranslations } from "../src/index";
import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard"; import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext"; import { PlatformModulesProvider } from "../src/platform/ModuleContext";
@@ -39,7 +40,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<PlatformModulesProvider modules={CONFORMANCE_MODULES}> <PlatformModulesProvider modules={CONFORMANCE_MODULES}>
<PlatformLanguageProvider <PlatformLanguageProvider
preferredLanguageCode="de" preferredLanguageCode="de"
moduleTranslations={[formsRuntimeTranslations]}> moduleTranslations={[formsRuntimeTranslations, productSurfaceTranslations]}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<Routes> <Routes>
<Route path="/forms/public/:publicId" element={<ConformanceApp />} /> <Route path="/forms/public/:publicId" element={<ConformanceApp />} />
@@ -295,6 +295,33 @@ test("View focus has a deliberate permission-derived all-tools escape", async ({
await expect(page.getByRole("button", { name: "Messages" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Messages" })).toHaveCount(0);
}); });
test("product navigation hides package topology behind stable bilingual destinations", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/?theme=light&product-navigation=1");
await page.getByRole("button", { name: "Expand navigation" }).click();
const primary = page.locator(".icon-nav > .icon-nav-group");
await expect(primary.getByRole("link")).toHaveText([
"Arbeit",
"Dateien",
"Nachrichten",
"Kalender"
]);
await expect(primary.getByRole("link", { name: /Tasks|Files|Mail|Postbox|Calendar/ })).toHaveCount(0);
const allTools = page.locator("[data-product-navigation='all-tools']");
await expect(allTools.getByText("Alle verfügbaren Werkzeuge", { exact: true })).toBeVisible();
await allTools.locator("summary").click();
await expect(allTools.getByRole("link")).toHaveText([
"Tasks",
"Files",
"Mail",
"Postbox",
"Calendar"
]);
await expectNoAccessibilityViolations(page);
});
test("a stale View focus falls back safely in a sparse optional-module catalogue", async ({ page }) => { test("a stale View focus falls back safely in a sparse optional-module catalogue", async ({ page }) => {
await page.route("**/api/v1/quick-access/effective*", async (route) => { await page.route("**/api/v1/quick-access/effective*", async (route) => {
await route.fulfill({ await route.fulfill({
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.43", "version": "0.1.44",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.43", "version": "0.1.44",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
+16 -16
View File
@@ -1,22 +1,22 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.43", "version": "0.1.44",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.43", "version": "0.1.44",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24", "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20", "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22", "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.23",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27",
"@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20", "@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20", "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22", "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.24", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.25",
"@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20", "@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24", "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.26", "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.26",
@@ -805,10 +805,10 @@
} }
}, },
"node_modules/@govoplan/calendar-webui": { "node_modules/@govoplan/calendar-webui": {
"version": "0.1.22", "version": "0.1.23",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#3792e9ab8a10ff6e301c59df7a3fa47d5ab14952", "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#3e5fc05ca3728464131067d1fa1bc25befeae939",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.44",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
@@ -894,10 +894,10 @@
} }
}, },
"node_modules/@govoplan/files-webui": { "node_modules/@govoplan/files-webui": {
"version": "0.1.24", "version": "0.1.25",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#6176e9f40e070387f03c15d10b3b3d2f132508b5", "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#2baa8f265707a4c3dd6bfaba78d86ac7a0edc7bb",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.44",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
@@ -2027,9 +2027,9 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.18", "version": "2.11.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz",
"integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.cjs" "baseline-browser-mapping": "dist/cli.cjs"
@@ -2349,9 +2349,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.5", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.43", "version": "0.1.44",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -22,6 +22,10 @@
"./wysiwyg": { "./wysiwyg": {
"types": "./src/wysiwyg.ts", "types": "./src/wysiwyg.ts",
"import": "./src/wysiwyg.ts" "import": "./src/wysiwyg.ts"
},
"./outcome-product-surface-translations": {
"types": "./src/i18n/outcomeProductSurfaceTranslations.ts",
"import": "./src/i18n/outcomeProductSurfaceTranslations.ts"
} }
}, },
"scripts": { "scripts": {
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.43", "version": "0.1.44",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -29,11 +29,11 @@
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24", "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20", "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22", "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.23",
"@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20", "@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20", "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22", "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.24", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.25",
"@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20", "@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24", "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.26", "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.26",
+6 -2
View File
@@ -8,7 +8,7 @@ import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage"; import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal"; import LoginModal from "./features/auth/LoginModal";
import { PermissionBoundary } from "./components/AccessBoundary"; import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules"; import { configurableNavigationItemsForModules, firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext"; import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PlatformViewProvider } from "./platform/ViewContext"; import { PlatformViewProvider } from "./platform/ViewContext";
import { PlatformTemporalProvider } from "./platform/TemporalContext"; import { PlatformTemporalProvider } from "./platform/TemporalContext";
@@ -73,6 +73,10 @@ export default function App() {
() => navItemsForModules(webModules, viewProjection), () => navItemsForModules(webModules, viewProjection),
[viewProjection, webModules] [viewProjection, webModules]
); );
const allToolItems = useMemo(
() => configurableNavigationItemsForModules(webModules),
[webModules]
);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]); const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]); const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules; const contextModules = auth ? webModules : publicWebModules;
@@ -550,7 +554,7 @@ export default function App() {
<PlatformViewProvider modules={webModules} projection={viewProjection}> <PlatformViewProvider modules={webModules} projection={viewProjection}>
<PlatformActiveObjectProvider> <PlatformActiveObjectProvider>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}> <AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} allToolItems={allToolItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}> <ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}>
<Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}> <Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} /> <Route path="/" element={<Navigate to={defaultRoute} replace />} />
+1 -4
View File
@@ -43,10 +43,7 @@ export const DEFAULT_AVAILABLE_LANGUAGES: PlatformLanguage[] = [
{ code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" }]; { code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" }];
export const DEFAULT_TRANSLATIONS: PlatformTranslations = { export const DEFAULT_TRANSLATIONS: PlatformTranslations = generatedTranslations;
en: generatedTranslations.en,
de: generatedTranslations.de
};
const PlatformLanguageContext = createContext<PlatformLanguageContextValue | null>(null); const PlatformLanguageContext = createContext<PlatformLanguageContextValue | null>(null);
+2
View File
@@ -138,6 +138,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder", "i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder",
"i18n:govoplan-core.application_notices": "Application notices", "i18n:govoplan-core.application_notices": "Application notices",
"i18n:govoplan-core.more_tools": "More tools", "i18n:govoplan-core.more_tools": "More tools",
"i18n:govoplan-core.all_available_tools": "All available tools",
"i18n:govoplan-core.product_area.work": "Work", "i18n:govoplan-core.product_area.work": "Work",
"i18n:govoplan-core.product_area.services_cases": "Services and cases", "i18n:govoplan-core.product_area.services_cases": "Services and cases",
"i18n:govoplan-core.product_area.communication": "Communication", "i18n:govoplan-core.product_area.communication": "Communication",
@@ -874,6 +875,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder", "i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder",
"i18n:govoplan-core.application_notices": "Anwendungshinweise", "i18n:govoplan-core.application_notices": "Anwendungshinweise",
"i18n:govoplan-core.more_tools": "Weitere Werkzeuge", "i18n:govoplan-core.more_tools": "Weitere Werkzeuge",
"i18n:govoplan-core.all_available_tools": "Alle verfügbaren Werkzeuge",
"i18n:govoplan-core.product_area.work": "Arbeit", "i18n:govoplan-core.product_area.work": "Arbeit",
"i18n:govoplan-core.product_area.services_cases": "Leistungen und Vorgänge", "i18n:govoplan-core.product_area.services_cases": "Leistungen und Vorgänge",
"i18n:govoplan-core.product_area.communication": "Kommunikation", "i18n:govoplan-core.product_area.communication": "Kommunikation",
@@ -0,0 +1,28 @@
import type { PlatformTranslations } from "../types";
export const generatedTranslations = {
en: {
"i18n:govoplan-core.product_surface.work": "Work",
"i18n:govoplan-core.product_surface.work_description": "Review and resume authorized work without navigating by package ownership.",
"i18n:govoplan-core.product_surface.calendar": "Calendar",
"i18n:govoplan-core.product_surface.calendar_description": "Plan and review authorized events through one stable calendar destination.",
"i18n:govoplan-core.product_surface.files": "Files",
"i18n:govoplan-core.product_surface.files_description": "Find, select, and manage authorized files without exposing their storage implementation.",
"i18n:govoplan-core.product_surface.unavailable": "Destination is unavailable",
"i18n:govoplan-core.product_surface.unavailable_description": "No product destination is available for your current responsibility and permissions.",
"i18n:govoplan-core.product_surface.unavailable_resolution": "Ask the responsible access administrator to review your assignment or permissions.",
"i18n:govoplan-core.access_administrator": "Access administrator"
},
de: {
"i18n:govoplan-core.product_surface.work": "Arbeit",
"i18n:govoplan-core.product_surface.work_description": "Berechtigte Arbeit prüfen und fortsetzen, ohne nach Paketzuständigkeit zu navigieren.",
"i18n:govoplan-core.product_surface.calendar": "Kalender",
"i18n:govoplan-core.product_surface.calendar_description": "Berechtigte Termine über ein stabiles Kalenderziel planen und prüfen.",
"i18n:govoplan-core.product_surface.files": "Dateien",
"i18n:govoplan-core.product_surface.files_description": "Berechtigte Dateien finden, auswählen und verwalten, ohne ihre Speicherimplementierung offenzulegen.",
"i18n:govoplan-core.product_surface.unavailable": "Das Produktziel ist nicht verfügbar",
"i18n:govoplan-core.product_surface.unavailable_description": "Für Ihre aktuelle Verantwortung und Berechtigungen ist kein Produktziel verfügbar.",
"i18n:govoplan-core.product_surface.unavailable_resolution": "Bitten Sie die zuständige Zugriffsadministration, Ihre Zuordnung oder Berechtigungen zu prüfen.",
"i18n:govoplan-core.access_administrator": "Zugriffsadministration"
}
} satisfies PlatformTranslations;
+3 -1
View File
@@ -33,7 +33,9 @@ export * from "./platform/moduleEvents";
export * from "./platform/ViewContext"; export * from "./platform/ViewContext";
export * from "./platform/views"; export * from "./platform/views";
export * from "./platform/productSurfaces"; export * from "./platform/productSurfaces";
export { generatedTranslations as messagesProductSurfaceTranslations } from "./i18n/productSurfaceTranslations"; export { productSurfaceTranslations } from "./productSurfaceTranslations";
export { generatedTranslations as messagesProductSurfaceTranslations } from "./i18n/messagesProductSurfaceTranslations";
export { generatedTranslations as outcomeProductSurfaceTranslations } from "./i18n/outcomeProductSurfaceTranslations";
export * from "./platform/temporal"; export * from "./platform/temporal";
export * from "./platform/TemporalContext"; export * from "./platform/TemporalContext";
export * from "./platform/ActiveObjectContext"; export * from "./platform/ActiveObjectContext";
+60
View File
@@ -0,0 +1,60 @@
import { Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router";
import type { MouseEvent } from "react";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformNavItem } from "../types";
export default function AllToolsNavigation({
items,
rememberedTargets
}: {
items: PlatformNavItem[];
rememberedTargets: Record<string, string>;
}) {
const location = useLocation();
const navigate = useGuardedNavigate();
const { translateText } = usePlatformLanguage();
function handleClick(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 (
<details className="icon-nav-all-tools" data-product-navigation="all-tools">
<summary
className="icon-nav-all-tools-summary"
title={translateText("i18n:govoplan-core.all_available_tools")}
>
<Settings size={20} aria-hidden="true" />
<span className="icon-nav-label">
{translateText("i18n:govoplan-core.all_available_tools")}
</span>
</summary>
<div className="icon-nav-all-tools-items">
{items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to;
const renderedLabel = translateText(label);
return (
<NavLink
key={to}
to={target}
className={`icon-nav-item ${pathActive(location.pathname, to) ? "active" : ""}`}
title={renderedLabel}
onClick={(event) => handleClick(event, target)}
>
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
<span className="icon-nav-label">{renderedLabel}</span>
</NavLink>
);
})}
</div>
</details>
);
}
function pathActive(pathname: string, root: string): boolean {
return pathname === root || pathname.startsWith(`${root}/`);
}
+10 -3
View File
@@ -11,6 +11,7 @@ import { useActiveObject } from "../platform/ActiveObjectContext";
import { createQuickAccessLaunchContext } from "../platform/launchContext"; import { createQuickAccessLaunchContext } from "../platform/launchContext";
import { isViewSurfaceVisible } from "../platform/views"; import { isViewSurfaceVisible } from "../platform/views";
import { hasAnyScope, hasScope } from "../utils/permissions"; import { hasAnyScope, hasScope } from "../utils/permissions";
import { projectProductNavigation } from "../platform/productSurfaces";
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
@@ -20,6 +21,7 @@ type Props = {
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void; onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
publicMode?: boolean; publicMode?: boolean;
navItems?: PlatformNavItem[]; navItems?: PlatformNavItem[];
allToolItems?: PlatformNavItem[];
maintenanceMode?: { enabled: boolean; message?: string | null }; maintenanceMode?: { enabled: boolean; message?: string | null };
backendReachable?: boolean; backendReachable?: boolean;
}; };
@@ -32,6 +34,7 @@ export default function AppShell({
onAuthChange, onAuthChange,
publicMode = false, publicMode = false,
navItems = [], navItems = [],
allToolItems = navItems,
maintenanceMode, maintenanceMode,
backendReachable = true backendReachable = true
}: Props) { }: Props) {
@@ -68,11 +71,15 @@ export default function AppShell({
() => modules.flatMap((module) => module.productAreas ?? []), () => modules.flatMap((module) => module.productAreas ?? []),
[modules] [modules]
); );
const productNavigation = useMemo(
() => projectProductNavigation(navItems, modules, auth, projection, allToolItems),
[allToolItems, auth, modules, navItems, projection]
);
if (publicMode) { if (publicMode) {
return ( return (
<div className="app-shell public-shell"> <div className="app-shell public-shell">
<IconRail compact auth={auth} navItems={navItems} /> <IconRail compact navItems={navItems} />
<div className="app-main public-main"> <div className="app-main public-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} /> <Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
<main className="public-content">{children}</main> <main className="public-content">{children}</main>
@@ -84,8 +91,8 @@ export default function AppShell({
return ( return (
<div className="app-shell"> <div className="app-shell">
<IconRail <IconRail
auth={auth} navItems={productNavigation.primaryItems}
navItems={navItems} allToolItems={productNavigation.allToolItems}
productAreas={productAreas} productAreas={productAreas}
presentation={projection?.presentation} presentation={projection?.presentation}
/> />
+22 -21
View File
@@ -1,45 +1,35 @@
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react"; import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router"; import { NavLink, useLocation } from "react-router";
import { useEffect, useMemo, useState, type MouseEvent } from "react"; import { lazy, Suspense, useEffect, useMemo, useState, type MouseEvent } from "react";
import type { import type {
AuthInfo,
PlatformNavItem, PlatformNavItem,
ProductAreaContribution, ProductAreaContribution,
ViewPresentation ViewPresentation
} from "../types"; } from "../types";
import { hasAnyScope, hasScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard"; import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { groupNavigationItems } from "../platform/productAreas"; import { groupNavigationItems } from "../platform/productAreas";
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav"; const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded"; const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
const AllToolsNavigation = lazy(() => import("./AllToolsNavigation"));
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;
});
}
export default function IconRail({ export default function IconRail({
compact = false, compact = false,
auth = null,
navItems = [], navItems = [],
allToolItems = [],
productAreas = [], productAreas = [],
presentation presentation
}: { }: {
compact?: boolean; compact?: boolean;
auth?: AuthInfo | null;
navItems?: PlatformNavItem[]; navItems?: PlatformNavItem[];
allToolItems?: PlatformNavItem[];
productAreas?: ProductAreaContribution[]; productAreas?: ProductAreaContribution[];
presentation?: ViewPresentation; presentation?: ViewPresentation;
}) { }) {
const location = useLocation(); const location = useLocation();
const items = visibleNavItems(auth, navItems); const items = navItems;
const technicalItems = allToolItems;
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets()); const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
const [expanded, setExpanded] = useState(() => loadRailExpanded()); const [expanded, setExpanded] = useState(() => loadRailExpanded());
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]); const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
@@ -95,9 +85,9 @@ export default function IconRail({
{translateText(group.label)} {translateText(group.label)}
</div> </div>
)} )}
{group.items.map(({ to, label, icon: Icon }) => { {group.items.map(({ to, label, icon: Icon, activePaths }) => {
const target = rememberedTargets[to] ?? to; const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to); const active = modulePathActive(location.pathname, to, activePaths);
const renderedLabel = translateText(label); const renderedLabel = translateText(label);
const areaLabel = group.areaLabel const areaLabel = group.areaLabel
? translateText(group.areaLabel) ? translateText(group.areaLabel)
@@ -120,6 +110,11 @@ export default function IconRail({
})} })}
</div> </div>
))} ))}
{technicalItems.length > 0 && (
<Suspense fallback={null}>
<AllToolsNavigation items={technicalItems} rememberedTargets={rememberedTargets} />
</Suspense>
)}
</nav> </nav>
</div> </div>
<div className="icon-rail-bottom"> <div className="icon-rail-bottom">
@@ -144,9 +139,15 @@ export default function IconRail({
} }
function modulePathActive(pathname: string, root: string): boolean { function modulePathActive(
if (root === "/") return pathname === "/"; pathname: string,
return pathname === root || pathname.startsWith(`${root}/`); root: string,
activePaths: string[] = []
): boolean {
return [root, ...activePaths].some((candidate) => {
if (candidate === "/") return pathname === "/";
return pathname === candidate || pathname.startsWith(`${candidate}/`);
});
} }
function loadRememberedTargets(): Record<string, string> { function loadRememberedTargets(): Record<string, string> {
+1 -9
View File
@@ -87,13 +87,5 @@ export function groupNavigationItems(
items: remaining items: remaining
}); });
} }
const overview = groups.filter((group) => group.id === "overview"); return groups;
const configurable = groups
.filter((group) => group.id !== "overview")
.sort((left, right) => minimumOrder(left.items) - minimumOrder(right.items));
return [...overview, ...configurable];
}
function minimumOrder(items: PlatformNavItem[]): number {
return Math.min(...items.map((item) => item.order ?? 100), 10_000);
} }
+73
View File
@@ -2,6 +2,7 @@ import type {
AuthInfo, AuthInfo,
ComposedProductSurface, ComposedProductSurface,
EffectiveViewProjection, EffectiveViewProjection,
PlatformNavItem,
ProductSurfaceMetadata, ProductSurfaceMetadata,
PlatformWebModule, PlatformWebModule,
ProductSurfaceContribution ProductSurfaceContribution
@@ -20,6 +21,11 @@ export type ProductSurfaceRouteResolvedEventDetail = {
usedAlias: boolean; usedAlias: boolean;
}; };
export type ProductNavigationProjection = {
primaryItems: PlatformNavItem[];
allToolItems: PlatformNavItem[];
};
export function composeProductSurfaces( export function composeProductSurfaces(
modules: readonly PlatformWebModule[] modules: readonly PlatformWebModule[]
): ComposedProductSurface[] { ): ComposedProductSurface[] {
@@ -110,6 +116,65 @@ export function availableProductSurfaceContributors(
}); });
} }
/**
* Replace authorized owner routes with stable product entries while retaining
* the complete permission-derived catalogue as an explicit escape.
*/
export function projectProductNavigation(
items: readonly PlatformNavItem[],
modules: readonly PlatformWebModule[],
auth: AuthInfo | null | undefined,
projection?: EffectiveViewProjection | null,
catalogueItems: readonly PlatformNavItem[] = items
): ProductNavigationProjection {
const authorizedItems = items.filter((item) => navigationItemAuthorized(item, auth));
const allToolItems = catalogueItems.filter((item) => navigationItemAuthorized(item, auth));
const ownerItemByPath = new Map(authorizedItems.map((item) => [item.to, item]));
const consumedOwnerPaths = new Set<string>();
const replacementByPath = new Map<string, PlatformNavItem>();
for (const surface of composeProductSurfaces(modules)) {
const contributors = availableProductSurfaceContributors(
surface,
auth,
modules,
projection
);
const navigable = contributors
.map((contribution) => ({
contribution,
item: ownerItemByPath.get(contribution.routePath)
}))
.filter((candidate): candidate is {
contribution: ProductSurfaceContribution;
item: PlatformNavItem;
} => candidate.item !== undefined);
const target = navigable[0];
if (!target) continue;
navigable.forEach(({ contribution }) => {
consumedOwnerPaths.add(contribution.routePath);
});
replacementByPath.set(target.contribution.routePath, {
...target.item,
to: surface.entryPath,
label: surface.label,
navigationId: surface.id,
activePaths: [
...surface.aliases,
...navigable.map(({ contribution }) => contribution.routePath)
]
});
}
const primaryItems = authorizedItems.flatMap((item) => {
const replacement = replacementByPath.get(item.to);
if (replacement) return [replacement];
return consumedOwnerPaths.has(item.to) ? [] : [item];
});
return { primaryItems, allToolItems };
}
export function dispatchProductSurfaceRouteResolved( export function dispatchProductSurfaceRouteResolved(
detail: ProductSurfaceRouteResolvedEventDetail detail: ProductSurfaceRouteResolvedEventDetail
): void { ): void {
@@ -141,3 +206,11 @@ function compareContributions(
): number { ): number {
return left.order - right.order || left.moduleId.localeCompare(right.moduleId); return left.order - right.order || left.moduleId.localeCompare(right.moduleId);
} }
function navigationItemAuthorized(
item: PlatformNavItem,
auth: AuthInfo | null | undefined
): boolean {
return !item.allOf?.some((scope) => !hasScope(auth, scope))
&& (!item.anyOf?.length || hasAnyScope(auth, item.anyOf));
}
+8
View File
@@ -0,0 +1,8 @@
import type { PlatformTranslations } from "./types";
import { generatedTranslations as messages } from "./i18n/messagesProductSurfaceTranslations";
import { generatedTranslations as outcomes } from "./i18n/outcomeProductSurfaceTranslations";
export const productSurfaceTranslations = {
en: { ...messages.en, ...outcomes.en },
de: { ...messages.de, ...outcomes.de }
} satisfies PlatformTranslations;
+10
View File
@@ -29,6 +29,16 @@
.icon-nav-label { display: none; min-width: 0; overflow: hidden; padding-right: 14px; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .icon-nav-label { display: none; min-width: 0; overflow: hidden; padding-right: 14px; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.icon-rail.expanded .icon-nav-label { display: block; } .icon-rail.expanded .icon-nav-label { display: block; }
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: var(--on-accent); border-left-color: var(--accent); } .icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: var(--on-accent); border-left-color: var(--accent); }
.icon-nav-all-tools { width: 100%; min-width: 0; border-top: 1px solid var(--rail-bg-active); }
.icon-nav-all-tools-summary { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; box-sizing: border-box; border-left: 3px solid transparent; color: var(--rail-text-muted); cursor: pointer; list-style: none; }
.icon-nav-all-tools-summary::-webkit-details-marker { display: none; }
.icon-nav-all-tools-summary > svg { justify-self: center; }
.icon-nav-all-tools-summary:hover,
.icon-nav-all-tools-summary:focus-visible,
.icon-nav-all-tools[open] > .icon-nav-all-tools-summary { background: var(--rail-bg-active); color: var(--on-accent); outline: none; }
.icon-nav-all-tools-summary:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.icon-nav-all-tools-items { width: 100%; min-width: 0; background: color-mix(in srgb, var(--rail-bg-active) 45%, var(--rail-bg)); }
.icon-nav-all-tools-items .icon-nav-item { min-height: 46px; height: 46px; }
.icon-rail.compact { width: 58px; } .icon-rail.compact { width: 58px; }
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); } .app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); } .titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); }
+2
View File
@@ -304,6 +304,8 @@ export type PlatformNavItem = {
navigationVisibilitySource?: string; navigationVisibilitySource?: string;
navigationLockSource?: string | null; navigationLockSource?: string | null;
navigationLayers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>; navigationLayers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>;
/** Additional stable or owner paths that should mark a composed product entry active. */
activePaths?: string[];
}; };
export type NavigationLayerState = { export type NavigationLayerState = {
+62 -1
View File
@@ -18,7 +18,10 @@ import {
visibleRoutesForProjection visibleRoutesForProjection
} from "../src/platform/views"; } from "../src/platform/views";
import { groupNavigationItems } from "../src/platform/productAreas"; import { groupNavigationItems } from "../src/platform/productAreas";
import { composeProductSurfaces } from "../src/platform/productSurfaces"; import {
composeProductSurfaces,
projectProductNavigation
} from "../src/platform/productSurfaces";
import { hasAnyScope, scopeGrants } from "../src/utils/permissions"; import { hasAnyScope, scopeGrants } from "../src/utils/permissions";
function assert(condition: unknown, message: string): void { function assert(condition: unknown, message: string): void {
@@ -260,6 +263,64 @@ assert(composedMessages[0]?.entryPath === "/messages", "the composed identity sh
assert(composedMessages[0]?.contributors.map((item) => item.moduleId).join(",") === "mail,postbox", "composition should retain ordered technical provenance"); assert(composedMessages[0]?.contributors.map((item) => item.moduleId).join(",") === "mail,postbox", "composition should retain ordered technical provenance");
assert(composedMessages[0]?.aliases.join(",") === "/inbox", "migration aliases should be de-duplicated across owners"); assert(composedMessages[0]?.aliases.join(",") === "/inbox", "migration aliases should be de-duplicated across owners");
const messageNavigation = projectProductNavigation(
[
{ to: "/dashboard", label: "Dashboard", order: 1 },
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] },
{ to: "/admin", label: "Administration", order: 90, anyOf: ["core:admin:read"] }
],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo
);
assert(
messageNavigation.primaryItems.map((item) => item.to).join(",") === "/dashboard,/messages",
"ordinary navigation should replace authorized owner routes with one stable product entry"
);
assert(
messageNavigation.primaryItems.find((item) => item.to === "/messages")?.activePaths?.includes("/postbox"),
"the product entry should remain active on an owner route"
);
assert(
messageNavigation.allToolItems.map((item) => item.to).join(",") === "/dashboard,/mail,/postbox",
"the explicit tool catalogue should retain authorized technical routes"
);
const mailOnlyNavigation = projectProductNavigation(
[
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] }
],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read"] } as AuthInfo
);
assert(
mailOnlyNavigation.primaryItems.map((item) => item.to).join(",") === "/messages",
"composition should remain stable when only one optional contributor is authorized"
);
assert(
mailOnlyNavigation.allToolItems.map((item) => item.to).join(",") === "/mail",
"the tool catalogue must not disclose unauthorized owner routes"
);
const focusedMessageNavigation = projectProductNavigation(
[{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] }],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo,
null,
[
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] }
]
);
assert(
focusedMessageNavigation.primaryItems.map((item) => item.to).join(",") === "/messages",
"a focused View should keep the selected stable product destination"
);
assert(
focusedMessageNavigation.allToolItems.map((item) => item.to).join(",") === "/mail,/postbox",
"All available tools should provide an explicit permission-derived escape from View focus"
);
const viewAwareFiles: PlatformWebModule = { const viewAwareFiles: PlatformWebModule = {
...files, ...files,
navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }], navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }],
+3
View File
@@ -31,6 +31,9 @@
], ],
"@govoplan/core-webui/wysiwyg": [ "@govoplan/core-webui/wysiwyg": [
"./src/wysiwyg.ts" "./src/wysiwyg.ts"
],
"@govoplan/core-webui/outcome-product-surface-translations": [
"./src/i18n/outcomeProductSurfaceTranslations.ts"
] ]
} }
}, },
+1
View File
@@ -250,6 +250,7 @@ export default defineConfig({
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) }, { find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/definition-graph", replacement: fileURLToPath(new URL("./src/definitionGraph.ts", import.meta.url)) }, { find: "@govoplan/core-webui/definition-graph", replacement: fileURLToPath(new URL("./src/definitionGraph.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/wysiwyg", replacement: fileURLToPath(new URL("./src/wysiwyg.ts", import.meta.url)) }, { find: "@govoplan/core-webui/wysiwyg", replacement: fileURLToPath(new URL("./src/wysiwyg.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/outcome-product-surface-translations", replacement: fileURLToPath(new URL("./src/i18n/outcomeProductSurfaceTranslations.ts", import.meta.url)) },
{ find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) } { find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) }
] ]
}, },