195 lines
7.4 KiB
TypeScript
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>);
|
|
|
|
}
|