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

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

View File

@@ -12,6 +12,7 @@ type Props = {
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
publicMode?: boolean;
navItems?: PlatformNavItem[];
maintenanceMode?: { enabled: boolean; message?: string | null };
};
export default function AppShell({
@@ -21,7 +22,8 @@ export default function AppShell({
onSettingsChange,
onAuthChange,
publicMode = false,
navItems = []
navItems = [],
maintenanceMode
}: Props) {
const location = useLocation();
@@ -30,7 +32,7 @@ export default function AppShell({
<div className="app-shell public-shell">
<IconRail compact auth={auth} navItems={navItems} />
<div className="app-main public-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} />
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} />
<main className="public-content">{children}</main>
</div>
</div>
@@ -41,7 +43,7 @@ export default function AppShell({
<div className="app-shell">
<IconRail auth={auth} navItems={navItems} />
<div className="app-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} />
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} />
<BreadcrumbBar pathname={location.pathname} />
<main className="app-content">{children}</main>
</div>

View File

@@ -1,64 +1,66 @@
import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export default function BreadcrumbBar({ pathname }: { pathname: string }) {
export default function BreadcrumbBar({ pathname }: {pathname: string;}) {
const parts = pathname.split("/").filter(Boolean);
const labels = parts.length ? parts : ["campaigns"];
const { translateText } = usePlatformLanguage();
return (
<div className="breadcrumb-bar">
<nav className="breadcrumbs" aria-label="Breadcrumb">
<nav className="breadcrumbs" aria-label={translateText("i18n:govoplan-core.breadcrumb.c766e665")}>
{labels.map((part, index) => {
const href = `/${labels.slice(0, index + 1).join("/")}`;
return (
<span className="crumb" key={`${part}-${index}`}>
<Link className="crumb-link" to={href}>{labelFor(part, labels, index)}</Link>
<Link className="crumb-link" to={href}>{translateText(labelFor(part, labels, index))}</Link>
{index < labels.length - 1 && <ChevronRight size={16} aria-hidden="true" />}
</span>
);
</span>);
})}
</nav>
</div>
);
</div>);
}
const campaignRouteLabels: Record<string, string> = {
data: "Sender & Recipients",
campaign: "Sender & Recipients",
settings: "Campaign settings",
"global-settings": "Campaign settings",
fields: "Fields",
recipients: "Sender & Recipients",
"recipient-data": "Recipient data",
template: "Template",
files: "Attachments",
attachments: "Attachments",
mail: "Server settings",
"mail-settings": "Server settings",
"server-settings": "Server settings",
review: "Review & Send",
send: "Review & Send",
report: "Report",
reports: "Report",
audit: "Audit log",
json: "JSON",
wizard: "Wizard",
create: "Create",
data: "i18n:govoplan-core.sender_recipients.922c6d24",
campaign: "i18n:govoplan-core.sender_recipients.922c6d24",
settings: "i18n:govoplan-core.campaign_settings.efffec26",
"global-settings": "i18n:govoplan-core.campaign_settings.efffec26",
fields: "i18n:govoplan-core.fields.e8b68527",
recipients: "i18n:govoplan-core.sender_recipients.922c6d24",
"recipient-data": "i18n:govoplan-core.recipient_data.c2baaf10",
template: "i18n:govoplan-core.template.3ec1ae06",
files: "i18n:govoplan-core.attachments.6771ade6",
attachments: "i18n:govoplan-core.attachments.6771ade6",
mail: "i18n:govoplan-core.server_settings.28af3cc7",
"mail-settings": "i18n:govoplan-core.server_settings.28af3cc7",
"server-settings": "i18n:govoplan-core.server_settings.28af3cc7",
review: "i18n:govoplan-core.review_send.1627617d",
send: "i18n:govoplan-core.review_send.1627617d",
report: "i18n:govoplan-core.report.ee45c303",
reports: "i18n:govoplan-core.report.ee45c303",
audit: "i18n:govoplan-core.audit_log.3cfc5f1c",
json: "i18n:govoplan-core.json.031a4e76",
wizard: "i18n:govoplan-core.wizard.127a661b",
create: "i18n:govoplan-core.create.6e157c5d"
};
const topLevelRouteLabels: Record<string, string> = {
campaigns: "Campaigns",
dashboard: "Dashboard",
templates: "Templates",
files: "Files",
"address-book": "Address Book",
reports: "Reports",
settings: "Settings",
admin: "Admin",
campaigns: "i18n:govoplan-core.campaigns.01a23a28",
dashboard: "i18n:govoplan-core.dashboard.d87f47b4",
templates: "i18n:govoplan-core.templates.f25b700e",
files: "i18n:govoplan-core.files.6ce6c512",
"address-book": "i18n:govoplan-core.address_book.f6327f59",
reports: "i18n:govoplan-core.reports.88bc3fe3",
settings: "i18n:govoplan-core.settings.c7f73bb5",
admin: "i18n:govoplan-core.admin.4e7afebc"
};
function labelFor(value: string, parts: string[], index: number): string {
if (parts[0] === "campaigns" && index === 1) return "Campaign";
if (parts[0] === "campaigns" && index === 1) return "i18n:govoplan-core.campaign.69390e16";
if (parts[0] === "campaigns" && index >= 2) {
const mapped = campaignRouteLabels[value];
if (mapped) return mapped;
@@ -66,6 +68,6 @@ function labelFor(value: string, parts: string[], index: number): string {
const mapped = topLevelRouteLabels[value];
if (mapped) return mapped;
if (value.length > 18) return "Campaign";
if (value.length > 18) return "i18n:govoplan-core.campaign.69390e16";
return value.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@@ -4,9 +4,11 @@ import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
import packageInfo from "../../package.json";
import Button from "../components/Button";
import Dialog from "../components/Dialog";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { usePlatformModules } from "../platform/ModuleContext";
import type { PlatformWebModule } from "../types";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export default function HelpMenu() {
const [open, setOpen] = useState(false);
@@ -14,8 +16,11 @@ export default function HelpMenu() {
const [contextOpen, setContextOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const navigate = useGuardedNavigate();
const helpContext = helpContextForPathname(location.pathname);
const modules = usePlatformModules();
const { translateText } = usePlatformLanguage();
const docsAvailable = modules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
useEffect(() => {
function openContextHelp(event: KeyboardEvent) {
@@ -44,6 +49,14 @@ export default function HelpMenu() {
setContextOpen(true);
}
function openDocs(type: "user" | "admin") {
if (!docsAvailable) return;
setOpen(false);
const params = new URLSearchParams({ type });
params.set("context", helpContext.id);
navigate(`/docs?${params.toString()}`);
}
return (
<div className="context-menu-wrap" ref={wrapRef}>
<button
@@ -56,68 +69,74 @@ export default function HelpMenu() {
openHelp();
}
}}
title="Help (F1)"
>
<HelpCircle size={17} /> Help
title={translateText("i18n:govoplan-core.help_f1")}>
<HelpCircle size={17} /> {translateText("i18n:govoplan-core.help.c47ae153")}
</button>
{open && (
<div className="dropdown-menu">
{open &&
<div className="dropdown-menu">
<button className="dropdown-item" onClick={openHelp}>
<HelpCircle size={16} /> Help <small>F1</small>
<HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small>
</button>
<hr />
<a className="dropdown-item" href="#" onClick={(e) => e.preventDefault()}><BookOpen size={16} /> User docs</a>
<a className="dropdown-item" href="#" onClick={(e) => e.preventDefault()}><BookOpen size={16} /> Admin docs</a>
<button className="dropdown-item" onClick={() => openDocs("user")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
</button>
<button className="dropdown-item" onClick={() => openDocs("admin")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
</button>
<hr />
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> GitLab</a>
<button className="dropdown-item" onClick={() => { setAboutOpen(true); setOpen(false); }}><Info size={16} /> About</button>
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> i18n:govoplan-core.gitlab.9a9b09d9</a>
<button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button>
</div>
)}
}
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
</div>
);
</div>);
}
function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose: () => void }) {
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) {
const { translateText } = usePlatformLanguage();
return (
<Dialog
open
title="Context help"
title={translateText("i18n:govoplan-core.context_help.7fc44ea1")}
onClose={onClose}
footer={<Button variant="primary" onClick={onClose}>Close</Button>}
>
footer={<Button variant="primary" onClick={onClose}>{translateText("i18n:govoplan-core.close.bbfa773e")}</Button>}>
<div className="help-panel-section" data-help-context={context.id}>
<h3>{context.title}</h3>
<p className="mono-small">Help context: {context.id}</p>
<p className="muted">This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter <span className="kbd">{helpQueryForContext(context)}</span>, to open the right page or section.</p>
<h3>{translateText(context.title)}</h3>
<p className="mono-small">{translateText("i18n:govoplan-core.help_context.61aed3b9")} {context.id}</p>
<p className="muted">{translateText("i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877")} <span className="kbd">{helpQueryForContext(context)}</span> {translateText("i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2")}</p>
</div>
<div className="help-panel-section">
<h3>Next actions</h3>
<p className="muted">The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.</p>
<h3>{translateText("i18n:govoplan-core.next_actions.7b09055a")}</h3>
<p className="muted">{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}</p>
</div>
</Dialog>
);
</Dialog>);
}
function AboutModal({ modules, onClose }: { modules: PlatformWebModule[]; onClose: () => void }) {
const moduleSummary = modules.length
? modules.map((module) => `${module.label} v${module.version}`).join(", ")
: "Core shell only";
function AboutModal({ modules, onClose }: {modules: PlatformWebModule[];onClose: () => void;}) {
const { translateText } = usePlatformLanguage();
const moduleSummary = modules.length ?
modules.map((module) => `${module.label} v${module.version}`).join(", ") :
translateText("i18n:govoplan-core.core_shell_only.4949b5d5");
return (
<Dialog
open
title="About GovOPlaN"
title={translateText("i18n:govoplan-core.about_govoplan.1c6884f8")}
onClose={onClose}
footer={<Button variant="primary" onClick={onClose}>Close</Button>}
>
footer={<Button variant="primary" onClick={onClose}>{translateText("i18n:govoplan-core.close.bbfa773e")}</Button>}>
<div className="about-logo" />
<p><strong>GovOPlaN WebUI</strong></p>
<p className="muted">Core WebUI version {packageInfo.version}</p>
<p>GovOPlaN is a modular platform runner for governed workflows, tenant-aware administration, managed files, mail settings and campaign execution.</p>
<p className="muted">Installed modules: {moduleSummary}</p>
<p><strong>i18n:govoplan-core.govoplan_webui.d4ff003b</strong></p>
<p className="muted">{translateText("i18n:govoplan-core.core_webui_version.a3c761e1")} {packageInfo.version}</p>
<p>{translateText("i18n:govoplan-core.govoplan_is_a_modular_platform_runner_for_govern.159abeab")}</p>
<p className="muted">{translateText("i18n:govoplan-core.installed_modules.cd6863dd")} {moduleSummary}</p>
<p><a href="https://add-ideas.de" target="_blank" rel="noreferrer">add-ideas.de</a></p>
</Dialog>
);
</Dialog>);
}

View File

@@ -1,34 +1,38 @@
import { Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router-dom";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import type { AuthInfo, PlatformNavItem } from "../types";
import { hasAnyScope, hasScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
return [...navItems]
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100))
.filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
return true;
});
return [...navItems].
sort((left, right) => (left.order ?? 100) - (right.order ?? 100)).
filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
return true;
});
}
export default function IconRail({
compact = false,
auth = null,
navItems = []
}: {
compact?: boolean;
auth?: AuthInfo | null;
navItems?: PlatformNavItem[];
}) {
}: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) {
const location = useLocation();
const items = visibleNavItems(auth, navItems);
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
const { translateText } = usePlatformLanguage();
const navigate = useGuardedNavigate();
useEffect(() => {
const currentRoot = topLevelItems.find((root) => modulePathActive(location.pathname, root));
@@ -42,32 +46,38 @@ export default function IconRail({
});
}, [location.hash, location.pathname, location.search, topLevelItems]);
function handleNavClick(event: MouseEvent<HTMLAnchorElement>, target: string) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
navigate(target);
}
return (
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
<div className="brand-mark" title="GovOPlaN">G</div>
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
{!compact && (
<>
{!compact &&
<>
<nav className="icon-nav">
{items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to);
return (
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={label}>
const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to);
return (
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={translateText(label)} onClick={(event) => handleNavClick(event, target)}>
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
</NavLink>
);
})}
</NavLink>);
})}
</nav>
<div className="icon-rail-bottom">
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title="Settings">
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
<Settings size={20} />
</NavLink>
</div>
</>
)}
</aside>
);
}
</aside>);
}
function modulePathActive(pathname: string, root: string): boolean {
@@ -85,7 +95,7 @@ function loadRememberedTargets(): Record<string, string> {
Object.entries(parsed).filter((entry): entry is [string, string] => {
const [root, target] = entry;
return typeof root === "string" && typeof target === "string" && root.startsWith("/") && target.startsWith(`${root}/`);
}),
})
);
} catch {
return {};
@@ -97,6 +107,7 @@ function saveRememberedTargets(targets: Record<string, string>): void {
try {
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
} catch {
// Remembered navigation is a convenience only.
}
}
}}

View File

@@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export default function LanguageMenu() {
const { language, selectableLanguages, setLanguage } = usePlatformLanguage();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (menuRef.current && !menuRef.current.contains(target)) setOpen(false);
}
window.addEventListener("mousedown", onPointerDown);
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
if (selectableLanguages.length <= 1) return null;
function selectLanguage(code: string) {
setLanguage(code);
setOpen(false);
}
return (
<div className="context-menu-wrap language-menu-wrap" ref={menuRef}>
<button className="titlebar-link language-menu-button" onClick={() => setOpen(!open)} aria-haspopup="menu" aria-expanded={open}>
<span className="language-menu-code">{language.toUpperCase()}</span>
<span className="tenant-caret"></span>
</button>
{open && (
<div className="dropdown-menu language-menu" role="menu">
{selectableLanguages.map((item) => {
const active = item.code === language;
return (
<button key={item.code} className={`dropdown-item ${active ? "active" : ""}`} onClick={() => selectLanguage(item.code)} role="menuitem">
<span className="language-option-code">{item.code.toUpperCase()}</span>
<span>{item.nativeLabel || item.label}</span>
{active && <Check size={16} />}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,3 +1,5 @@
import { usePlatformLanguage } from "../i18n/LanguageContext";
export type ModuleSubnavItem<T extends string> = {
id: T;
label: string;
@@ -29,34 +31,35 @@ export default function ModuleSubnav<T extends string>({
groups,
onSelect,
className = ""
}: {
active: T;
groups: ModuleSubnavGroup<T>[];
onSelect: (section: T) => void;
className?: string;
}) {
}: {active: T;groups: ModuleSubnavGroup<T>[];onSelect: (section: T) => void;className?: string;}) {
const { translateText } = usePlatformLanguage();
return (
<aside className={`section-sidebar ${className}`.trim()}>
{groups.map((group, groupIndex) => (
<div className="section-group" key={`${group.title ?? "group"}-${groupIndex}`}>
{group.title && <div className={`section-title ${groupIndex > 0 ? "section-title-lower" : ""}`.trim()}>{group.title}</div>}
{groups.map((group, groupIndex) =>
<div className="section-group" key={`${group.title ?? "group"}-${groupIndex}`}>
{group.title && <div className={`section-title ${groupIndex > 0 ? "section-title-lower" : ""}`.trim()}>{translateText(group.title)}</div>}
{group.items.map((entry) => {
const key = isAction(entry) ? entry.actionId : entry.id;
const activeClass = !isAction(entry) && active === entry.id ? " active" : "";
const primaryClass = entry.primary ? " section-link-primary" : "";
const subtleClass = entry.subtle ? " subtle" : "";
return (
<button
key={key}
className={`section-link${primaryClass}${subtleClass}${activeClass}`}
onClick={() => (isAction(entry) ? entry.onClick() : onSelect(entry.id))}
>
{entry.label}
</button>
);
})}
const key = isAction(entry) ? entry.actionId : entry.id;
const activeClass = !isAction(entry) && active === entry.id ? " active" : "";
const primaryClass = entry.primary ? " section-link-primary" : "";
const subtleClass = entry.subtle ? " subtle" : "";
return (
<button
key={key}
className={`section-link${primaryClass}${subtleClass}${activeClass}`}
onClick={() => isAction(entry) ? entry.onClick() : onSelect(entry.id)}>
{translateText(entry.label)}
</button>);
})}
</div>
))}
</aside>
);
)}
</aside>);
}

View File

@@ -1,23 +1,25 @@
import { useRef, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from "../types";
import HelpMenu from "./HelpMenu";
import LanguageMenu from "./LanguageMenu";
import LoginModal from "../features/auth/LoginModal";
import DismissibleAlert from "../components/DismissibleAlert";
import { useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { logout, switchTenant } from "../api/auth";
import { hasAnyScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext";
type Props = {
settings: ApiSettings;
auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
maintenanceMode?: {enabled: boolean;message?: string | null;};
};
export default function Titlebar({ settings, auth, onAuthChange }: Props) {
const navigate = useNavigate();
export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode }: Props) {
const navigate = useGuardedNavigate();
const { requestNavigation } = useUnsavedChanges();
const [accountOpen, setAccountOpen] = useState(false);
const [tenantOpen, setTenantOpen] = useState(false);
@@ -26,16 +28,18 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
const [tenantError, setTenantError] = useState("");
const accountRef = useRef<HTMLDivElement>(null);
const tenantRef = useRef<HTMLDivElement>(null);
const { translateText } = usePlatformLanguage();
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
const displayUserName = auth?.user?.display_name || auth?.user?.email || translateText("i18n:govoplan-core.sign_in.ada2e9e9");
const canSwitchTenant = tenants.length > 1;
const canAdministerTenants = hasAnyScope(auth, [
"system:tenants:read",
"system:tenants:create",
"system:tenants:update",
"system:tenants:suspend"
]);
"system:tenants:read",
"system:tenants:create",
"system:tenants:update",
"system:tenants:suspend"]
);
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
useEffect(() => {
@@ -53,25 +57,14 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
}, []);
function handleLogin(response: LoginResponse) {
const active = response.active_tenant ?? response.tenant;
onAuthChange(
{
user: response.user,
tenant: active,
active_tenant: active,
tenants: response.tenants ?? [active],
scopes: response.scopes,
roles: response.roles,
groups: response.groups
},
""
);
onAuthChange(response, "");
}
async function performLogout() {
try {
await logout(settings);
} catch {
// Logout is best effort; clear local session either way.
}
onAuthChange(null, "");
@@ -89,14 +82,14 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
onAuthChange(next);
setTenantOpen(false);
} catch (error) {
setTenantError(error instanceof Error ? error.message : "Tenant switch failed.");
setTenantError(error instanceof Error ? error.message : translateText("i18n:govoplan-core.tenant_switch_failed.c325b20b"));
} finally {
setSwitchingTenantId(null);
}
}
function handleLogout() {
requestNavigation(() => { void performLogout(); });
requestNavigation(() => {void performLogout();});
}
function handleTenantSelect(tenant: AuthTenantMembership) {
@@ -104,80 +97,98 @@ export default function Titlebar({ settings, auth, onAuthChange }: Props) {
setTenantOpen(false);
return;
}
requestNavigation(() => { void performTenantSelect(tenant); });
requestNavigation(() => {void performTenantSelect(tenant);});
}
function handleAccountSettings() {
setAccountOpen(false);
requestNavigation(() => navigate("/settings?section=profile"));
navigate("/settings?section=profile");
}
function openMaintenanceSettings() {
navigate("/admin?section=system-settings");
}
return (
<header className="titlebar">
{auth && activeTenant && showTenantControl && (
<div className="tenant-selector" ref={tenantRef}>
<span className="tenant-label">tenant:</span>
{canSwitchTenant ? (
<>
{maintenanceMode?.enabled &&
<button
type="button"
className="maintenance-topbar-link"
title={maintenanceMode.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
onClick={openMaintenanceSettings}>
{translateText("i18n:govoplan-core.maintenance_mode_enabled.4fb4a37d")}
</button>
}
{auth && activeTenant && showTenantControl &&
<div className="tenant-selector" ref={tenantRef}>
<span className="tenant-label">{translateText("i18n:govoplan-core.tenant_label_prefix")}</span>
{canSwitchTenant ?
<>
<button className="tenant-name-button" onClick={() => setTenantOpen(!tenantOpen)}>
<strong>{activeTenant.name}</strong>
<span className="tenant-caret"></span>
</button>
{tenantOpen && (
<div className="dropdown-menu tenant-menu">
{tenantOpen &&
<div className="dropdown-menu tenant-menu">
{tenants.map((tenant) => {
const active = tenant.id === activeTenant.id;
return (
<button
key={tenant.id}
className={`dropdown-item ${active ? "active" : ""}`}
onClick={() => void handleTenantSelect(tenant)}
disabled={Boolean(switchingTenantId)}
>
<span>{switchingTenantId === tenant.id ? "Switching…" : tenant.name}</span>
const active = tenant.id === activeTenant.id;
return (
<button
key={tenant.id}
className={`dropdown-item ${active ? "active" : ""}`}
onClick={() => void handleTenantSelect(tenant)}
disabled={Boolean(switchingTenantId)}>
<span>{switchingTenantId === tenant.id ? translateText("i18n:govoplan-core.switching.ca40b0ec") : tenant.name}</span>
{active && <Check size={16} />}
</button>
);
})}
</button>);
})}
{tenantError && <DismissibleAlert tone="danger" compact dismissible={false} className="dropdown-error">{tenantError}</DismissibleAlert>}
</div>
)}
</>
) : (
<strong>{activeTenant.name}</strong>
)}
}
</> :
<strong>{activeTenant.name}</strong>
}
</div>
)}
}
<div className="titlebar-spacer" />
<HelpMenu />
<LanguageMenu />
<div className="context-menu-wrap" ref={accountRef}>
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
<UserCircle size={22} />
<span>{auth?.user.display_name || auth?.user.email || "Sign in"}</span>
<span>{displayUserName}</span>
<span className="tenant-caret"></span>
</button>
{accountOpen && (
<div className="dropdown-menu account-menu">
{auth ? (
<>
{accountOpen &&
<div className="dropdown-menu account-menu">
{auth ?
<>
{auth.user &&
<div className="account-menu-header">
<strong>{auth.user.display_name || auth.user.email}</strong>
<span>{auth.user.email}</span>
</div>
<button className="dropdown-item" onClick={handleAccountSettings}><Settings size={16} /> Account settings</button>
<button className="dropdown-item" onClick={handleLogout}><LogOut size={16} /> Sign out</button>
</>
) : (
<button className="dropdown-item" onClick={() => { setLoginOpen(true); setAccountOpen(false); }}><UserCircle size={16} /> Sign in</button>
)}
}
<button className="dropdown-item" onClick={handleAccountSettings}><Settings size={16} /> {translateText("i18n:govoplan-core.account_settings.82cf8a5f")}</button>
<button className="dropdown-item" onClick={handleLogout}><LogOut size={16} /> {translateText("i18n:govoplan-core.sign_out.dc1649a1")}</button>
</> :
<button className="dropdown-item" onClick={() => {setLoginOpen(true);setAccountOpen(false);}}><UserCircle size={16} /> {translateText("i18n:govoplan-core.sign_in.ada2e9e9")}</button>
}
</div>
)}
}
</div>
{loginOpen && <LoginModal settings={settings} onClose={() => setLoginOpen(false)} onLogin={handleLogin} />}
</header>
);
</header>);
}