initial commit after split

This commit is contained in:
2026-06-24 01:43:10 +02:00
parent b1d6c0150f
commit 30c11a6dcf
173 changed files with 25380 additions and 0 deletions

View 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>
);
}