, target: string) {
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
+ event.preventDefault();
+ navigate(target);
+ }
+
+ return (
+
+
+
+
+ {translateText("i18n:govoplan-core.all_available_tools")}
+
+
+
+ {items.map(({ to, label, icon: Icon }) => {
+ const target = rememberedTargets[to] ?? to;
+ const renderedLabel = translateText(label);
+ return (
+ handleClick(event, target)}
+ >
+ {Icon ? : {renderedLabel.slice(0, 1)}}
+ {renderedLabel}
+
+ );
+ })}
+
+
+ );
+}
+
+function pathActive(pathname: string, root: string): boolean {
+ return pathname === root || pathname.startsWith(`${root}/`);
+}
diff --git a/webui/src/layout/AppShell.tsx b/webui/src/layout/AppShell.tsx
index 95a4264..e9d2956 100644
--- a/webui/src/layout/AppShell.tsx
+++ b/webui/src/layout/AppShell.tsx
@@ -11,6 +11,7 @@ import { useActiveObject } from "../platform/ActiveObjectContext";
import { createQuickAccessLaunchContext } from "../platform/launchContext";
import { isViewSurfaceVisible } from "../platform/views";
import { hasAnyScope, hasScope } from "../utils/permissions";
+import { projectProductNavigation } from "../platform/productSurfaces";
type Props = {
children: React.ReactNode;
@@ -20,6 +21,7 @@ type Props = {
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
publicMode?: boolean;
navItems?: PlatformNavItem[];
+ allToolItems?: PlatformNavItem[];
maintenanceMode?: { enabled: boolean; message?: string | null };
backendReachable?: boolean;
};
@@ -32,6 +34,7 @@ export default function AppShell({
onAuthChange,
publicMode = false,
navItems = [],
+ allToolItems = navItems,
maintenanceMode,
backendReachable = true
}: Props) {
@@ -68,11 +71,15 @@ export default function AppShell({
() => modules.flatMap((module) => module.productAreas ?? []),
[modules]
);
+ const productNavigation = useMemo(
+ () => projectProductNavigation(navItems, modules, auth, projection, allToolItems),
+ [allToolItems, auth, modules, navItems, projection]
+ );
if (publicMode) {
return (
-
+
{children}
@@ -84,8 +91,8 @@ export default function AppShell({
return (
diff --git a/webui/src/layout/IconRail.tsx b/webui/src/layout/IconRail.tsx
index e1db93e..7d08ffd 100644
--- a/webui/src/layout/IconRail.tsx
+++ b/webui/src/layout/IconRail.tsx
@@ -1,45 +1,35 @@
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
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 {
- AuthInfo,
PlatformNavItem,
ProductAreaContribution,
ViewPresentation
} from "../types";
-import { hasAnyScope, hasScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { groupNavigationItems } from "../platform/productAreas";
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
-
-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;
- });
-}
+const AllToolsNavigation = lazy(() => import("./AllToolsNavigation"));
export default function IconRail({
compact = false,
- auth = null,
navItems = [],
+ allToolItems = [],
productAreas = [],
presentation
}: {
compact?: boolean;
- auth?: AuthInfo | null;
navItems?: PlatformNavItem[];
+ allToolItems?: PlatformNavItem[];
productAreas?: ProductAreaContribution[];
presentation?: ViewPresentation;
}) {
const location = useLocation();
- const items = visibleNavItems(auth, navItems);
+ const items = navItems;
+ const technicalItems = allToolItems;
const [rememberedTargets, setRememberedTargets] = useState>(() => loadRememberedTargets());
const [expanded, setExpanded] = useState(() => loadRailExpanded());
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
@@ -95,9 +85,9 @@ export default function IconRail({
{translateText(group.label)}
)}
- {group.items.map(({ to, label, icon: Icon }) => {
+ {group.items.map(({ to, label, icon: Icon, activePaths }) => {
const target = rememberedTargets[to] ?? to;
- const active = modulePathActive(location.pathname, to);
+ const active = modulePathActive(location.pathname, to, activePaths);
const renderedLabel = translateText(label);
const areaLabel = group.areaLabel
? translateText(group.areaLabel)
@@ -120,6 +110,11 @@ export default function IconRail({
})}
))}
+ {technicalItems.length > 0 && (
+
+
+
+ )}
@@ -144,9 +139,15 @@ export default function IconRail({
}
-function modulePathActive(pathname: string, root: string): boolean {
- if (root === "/") return pathname === "/";
- return pathname === root || pathname.startsWith(`${root}/`);
+function modulePathActive(
+ pathname: string,
+ root: string,
+ activePaths: string[] = []
+): boolean {
+ return [root, ...activePaths].some((candidate) => {
+ if (candidate === "/") return pathname === "/";
+ return pathname === candidate || pathname.startsWith(`${candidate}/`);
+ });
}
function loadRememberedTargets(): Record {
diff --git a/webui/src/platform/productAreas.ts b/webui/src/platform/productAreas.ts
index 0ac48f3..556bd59 100644
--- a/webui/src/platform/productAreas.ts
+++ b/webui/src/platform/productAreas.ts
@@ -87,13 +87,5 @@ export function groupNavigationItems(
items: remaining
});
}
- const overview = groups.filter((group) => group.id === "overview");
- 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);
+ return groups;
}
diff --git a/webui/src/platform/productSurfaces.ts b/webui/src/platform/productSurfaces.ts
index 3b58dda..f3845d5 100644
--- a/webui/src/platform/productSurfaces.ts
+++ b/webui/src/platform/productSurfaces.ts
@@ -2,6 +2,7 @@ import type {
AuthInfo,
ComposedProductSurface,
EffectiveViewProjection,
+ PlatformNavItem,
ProductSurfaceMetadata,
PlatformWebModule,
ProductSurfaceContribution
@@ -20,6 +21,11 @@ export type ProductSurfaceRouteResolvedEventDetail = {
usedAlias: boolean;
};
+export type ProductNavigationProjection = {
+ primaryItems: PlatformNavItem[];
+ allToolItems: PlatformNavItem[];
+};
+
export function composeProductSurfaces(
modules: readonly PlatformWebModule[]
): 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();
+ const replacementByPath = new Map();
+
+ 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(
detail: ProductSurfaceRouteResolvedEventDetail
): void {
@@ -141,3 +206,11 @@ function compareContributions(
): number {
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));
+}
diff --git a/webui/src/productSurfaceTranslations.ts b/webui/src/productSurfaceTranslations.ts
new file mode 100644
index 0000000..8cbcc6d
--- /dev/null
+++ b/webui/src/productSurfaceTranslations.ts
@@ -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;
diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css
index 8609787..2be45fb 100644
--- a/webui/src/styles/layout.css
+++ b/webui/src/styles/layout.css
@@ -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-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-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; }
.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); }
diff --git a/webui/src/types.ts b/webui/src/types.ts
index 5f9d862..9c8210f 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -304,6 +304,8 @@ export type PlatformNavItem = {
navigationVisibilitySource?: string;
navigationLockSource?: string | null;
navigationLayers?: Partial>;
+ /** Additional stable or owner paths that should mark a composed product entry active. */
+ activePaths?: string[];
};
export type NavigationLayerState = {
diff --git a/webui/tests/module-capabilities.test.ts b/webui/tests/module-capabilities.test.ts
index 7d581d7..37db89b 100644
--- a/webui/tests/module-capabilities.test.ts
+++ b/webui/tests/module-capabilities.test.ts
@@ -18,7 +18,10 @@ import {
visibleRoutesForProjection
} from "../src/platform/views";
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";
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]?.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 = {
...files,
navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }],
diff --git a/webui/tsconfig.json b/webui/tsconfig.json
index 5c8a42e..d93c5bf 100644
--- a/webui/tsconfig.json
+++ b/webui/tsconfig.json
@@ -31,6 +31,9 @@
],
"@govoplan/core-webui/wysiwyg": [
"./src/wysiwyg.ts"
+ ],
+ "@govoplan/core-webui/outcome-product-surface-translations": [
+ "./src/i18n/outcomeProductSurfaceTranslations.ts"
]
}
},
diff --git a/webui/vite.config.ts b/webui/vite.config.ts
index 75d4e95..760a62f 100644
--- a/webui/vite.config.ts
+++ b/webui/vite.config.ts
@@ -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/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/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)) }
]
},