Files
govoplan-core/webui/src/layout/Titlebar.tsx
Albrecht Degering 635d25c74c
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled
chore: consolidate platform split checks
2026-07-10 12:51:19 +02:00

195 lines
7.4 KiB
TypeScript

import { useRef, useState, useEffect } from "react";
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 { 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, maintenanceMode }: Props) {
const navigate = useGuardedNavigate();
const { requestNavigation } = useUnsavedChanges();
const [accountOpen, setAccountOpen] = useState(false);
const [tenantOpen, setTenantOpen] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null);
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"]
);
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
useEffect(() => {
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (accountRef.current && !accountRef.current.contains(target)) {
setAccountOpen(false);
}
if (tenantRef.current && !tenantRef.current.contains(target)) {
setTenantOpen(false);
}
}
window.addEventListener("mousedown", onPointerDown);
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
function handleLogin(response: LoginResponse) {
onAuthChange(response, "");
}
async function performLogout() {
try {
await logout(settings);
} catch {
// Logout is best effort; clear local session either way.
}
onAuthChange(null, "");
setAccountOpen(false);
}
async function performTenantSelect(tenant: AuthTenantMembership) {
setSwitchingTenantId(tenant.id);
setTenantError("");
try {
const next = await switchTenant(settings, tenant.id);
// Keep the current URL. The tenant-keyed route tree reloads in the new
// context; permission and resource boundaries then retain the page or
// fall back to the nearest accessible parent.
onAuthChange(next);
setTenantOpen(false);
} catch (error) {
setTenantError(error instanceof Error ? error.message : translateText("i18n:govoplan-core.tenant_switch_failed.c325b20b"));
} finally {
setSwitchingTenantId(null);
}
}
function handleLogout() {
requestNavigation(() => {void performLogout();});
}
function handleTenantSelect(tenant: AuthTenantMembership) {
if (!auth || tenant.id === activeTenant?.id || switchingTenantId) {
setTenantOpen(false);
return;
}
requestNavigation(() => {void performTenantSelect(tenant);});
}
function handleAccountSettings() {
setAccountOpen(false);
navigate("/settings?section=profile");
}
function openMaintenanceSettings() {
navigate("/admin?section=system-settings");
}
return (
<header className="titlebar">
{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">
{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 ? translateText("i18n:govoplan-core.switching.ca40b0ec") : tenant.name}</span>
{active && <Check size={16} />}
</button>);
})}
{tenantError && <DismissibleAlert tone="danger" compact dismissible={false} className="dropdown-error">{tenantError}</DismissibleAlert>}
</div>
}
</> :
<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>{displayUserName}</span>
<span className="tenant-caret"></span>
</button>
{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} /> {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>);
}