initial commit after split
This commit is contained in:
168
webui/src/layout/Titlebar.tsx
Normal file
168
webui/src/layout/Titlebar.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
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 LoginModal from "../features/auth/LoginModal";
|
||||
import DismissibleAlert from "../components/DismissibleAlert";
|
||||
import { logout, switchTenant } from "../api/auth";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo | null;
|
||||
onSettingsChange: (settings: ApiSettings) => void;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
};
|
||||
|
||||
export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
const navigate = useNavigate();
|
||||
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 activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||
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) {
|
||||
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
|
||||
},
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logout(settings);
|
||||
} catch {
|
||||
// Logout is best effort; clear local session either way.
|
||||
}
|
||||
onAuthChange(null, "");
|
||||
setAccountOpen(false);
|
||||
}
|
||||
|
||||
async function handleTenantSelect(tenant: AuthTenantMembership) {
|
||||
if (!auth || tenant.id === activeTenant?.id || switchingTenantId) {
|
||||
setTenantOpen(false);
|
||||
return;
|
||||
}
|
||||
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 : "Tenant switch failed.");
|
||||
} finally {
|
||||
setSwitchingTenantId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="titlebar">
|
||||
{auth && activeTenant && showTenantControl && (
|
||||
<div className="tenant-selector" ref={tenantRef}>
|
||||
<span className="tenant-label">tenant:</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 ? "Switching…" : 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 />
|
||||
|
||||
<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 className="tenant-caret">▾</span>
|
||||
</button>
|
||||
{accountOpen && (
|
||||
<div className="dropdown-menu account-menu">
|
||||
{auth ? (
|
||||
<>
|
||||
<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={() => { setAccountOpen(false); navigate("/settings?section=profile"); }}><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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loginOpen && <LoginModal settings={settings} onClose={() => setLoginOpen(false)} onLogin={handleLogin} />}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user