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
+6 -2
View File
@@ -8,7 +8,7 @@ import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal";
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 { PlatformViewProvider } from "./platform/ViewContext";
import { PlatformTemporalProvider } from "./platform/TemporalContext";
@@ -73,6 +73,10 @@ export default function App() {
() => navItemsForModules(webModules, viewProjection),
[viewProjection, webModules]
);
const allToolItems = useMemo(
() => configurableNavigationItemsForModules(webModules),
[webModules]
);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules;
@@ -550,7 +554,7 @@ export default function App() {
<PlatformViewProvider modules={webModules} projection={viewProjection}>
<PlatformActiveObjectProvider>
<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}>
<Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
<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" }];
export const DEFAULT_TRANSLATIONS: PlatformTranslations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const DEFAULT_TRANSLATIONS: PlatformTranslations = generatedTranslations;
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.application_notices": "Application notices",
"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.services_cases": "Services and cases",
"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.application_notices": "Anwendungshinweise",
"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.services_cases": "Leistungen und Vorgänge",
"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/views";
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/TemporalContext";
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 { 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 (
<div className="app-shell public-shell">
<IconRail compact auth={auth} navItems={navItems} />
<IconRail compact navItems={navItems} />
<div className="app-main public-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
<main className="public-content">{children}</main>
@@ -84,8 +91,8 @@ export default function AppShell({
return (
<div className="app-shell">
<IconRail
auth={auth}
navItems={navItems}
navItems={productNavigation.primaryItems}
allToolItems={productNavigation.allToolItems}
productAreas={productAreas}
presentation={projection?.presentation}
/>
+22 -21
View File
@@ -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<Record<string, string>>(() => 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)}
</div>
)}
{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({
})}
</div>
))}
{technicalItems.length > 0 && (
<Suspense fallback={null}>
<AllToolsNavigation items={technicalItems} rememberedTargets={rememberedTargets} />
</Suspense>
)}
</nav>
</div>
<div className="icon-rail-bottom">
@@ -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<string, string> {
+1 -9
View File
@@ -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;
}
+73
View File
@@ -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<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(
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));
}
+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-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); }
+2
View File
@@ -304,6 +304,8 @@ export type PlatformNavItem = {
navigationVisibilitySource?: string;
navigationLockSource?: string | null;
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 = {