1
0
forked from baron/baron-sso
Files
baron-sso/devfront/src/components/layout/AppLayout.tsx
2026-05-18 18:06:13 +09:00

534 lines
17 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import {
ChevronDown,
ClipboardCheck,
LayoutDashboard,
LogOut,
Moon,
NotebookTabs,
ShieldHalf,
Sun,
User as UserIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
AppSidebar,
type ShellSidebarNavItem,
type ShellTranslator,
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellTheme,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
import { fetchMe } from "../../features/auth/authApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
const navItems: ShellSidebarNavItem[] = [
{
labelKey: "ui.dev.nav.overview",
labelFallback: "Overview",
to: "/",
icon: LayoutDashboard,
end: true,
},
{
labelKey: "ui.dev.nav.developer_request",
labelFallback: "Developer Access Request",
to: "/developer-requests",
icon: ClipboardCheck,
},
{
labelKey: "ui.dev.nav.clients",
labelFallback: "Clients",
to: "/clients",
icon: ShieldHalf,
},
{
labelKey: "ui.dev.nav.audit_logs",
labelFallback: "Audit Logs",
to: "/audit-logs",
icon: NotebookTabs,
},
];
type SessionStatusProps = {
expiresAtSec?: number | null;
t: ShellTranslator;
};
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
}
function SessionStatusBadge(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return (
<span
className={[
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionStatus.text}
</span>
);
}
function SessionStatusText(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return <>{sessionStatus.text}</>;
}
function AppLayout() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const profileMenuRef = useRef<HTMLDivElement>(null);
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null);
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
);
const hasAccessToken = Boolean(auth.user?.access_token);
const { data: profile } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const handleLogout = () => {
if (
window.confirm(
t("msg.dev.logout_confirm", "Are you sure you want to log out?"),
)
) {
auth.removeUser();
navigate("/login");
}
};
useEffect(() => {
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
profileMenuRef.current &&
!profileMenuRef.current.contains(event.target as Node)
) {
setIsProfileMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
useEffect(() => {
const maybeRenewSession = async () => {
const now = Date.now();
if (
!shouldAttemptSlidingSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
try {
await auth.signinSilent();
} catch (error) {
console.error("Silent session renewal failed.", error);
} finally {
isRenewInFlightRef.current = false;
}
};
const handleUserAction = () => {
void maybeRenewSession();
};
window.addEventListener("pointerdown", handleUserAction);
window.addEventListener("keydown", handleUserAction);
return () => {
window.removeEventListener("pointerdown", handleUserAction);
window.removeEventListener("keydown", handleUserAction);
};
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
]);
useEffect(() => {
const maybeKeepSessionAlive = async () => {
const now = Date.now();
if (
!shouldAttemptUnlimitedSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
try {
await auth.signinSilent();
} catch (error) {
console.error("Unlimited session keepalive renewal failed.", error);
} finally {
isRenewInFlightRef.current = false;
}
};
const timer = window.setInterval(() => {
void maybeKeepSessionAlive();
}, 30_000);
void maybeKeepSessionAlive();
return () => {
window.clearInterval(timer);
};
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
]);
useEffect(() => {
const routeKey = `${location.pathname}${location.search}${location.hash}`;
if (lastVisitedRouteRef.current === null) {
lastVisitedRouteRef.current = routeKey;
return;
}
if (lastVisitedRouteRef.current === routeKey) {
return;
}
lastVisitedRouteRef.current = routeKey;
const now = Date.now();
if (
!shouldAttemptSlidingSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
void auth
.signinSilent()
.catch((error) => {
console.error("Silent session renewal failed.", error);
})
.finally(() => {
isRenewInFlightRef.current = false;
});
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
location.hash,
location.pathname,
location.search,
]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const profileSummary = buildShellProfileSummary({
profileName:
profile?.name ||
auth.user?.profile?.name?.toString() ||
auth.user?.profile?.preferred_username?.toString() ||
auth.user?.profile?.nickname?.toString(),
profileEmail: profile?.email || auth.user?.profile?.email?.toString(),
fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
});
const currentRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const displayRoleKey = profile?.role || currentRole;
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
writeShellSessionExpiryEnabled(next);
return next;
});
};
const sidebarNavContent = (
<div className={shellLayoutClasses.navList}>
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
))}
</div>
);
const sidebarFooterContent = (
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
onClick={handleLogout}
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
);
return (
<div className={shellLayoutClasses.root}>
<AppSidebar
brandLabel={t("ui.dev.brand", "Baron Sign In")}
brandTitle={t("ui.dev.console_title", "Developer Console")}
brandIcon={<ShieldHalf size={20} />}
navContent={sidebarNavContent}
footerContent={sidebarFooterContent}
/>
<div className={shellLayoutClasses.content}>
<header className={shellLayoutClasses.header}>
<div className={shellLayoutClasses.headerInner}>
<div className={shellLayoutClasses.headerTitleWrap}>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.dev.header.plane", "Dev Plane")}
</p>
<span className="text-lg font-semibold">
{t("ui.dev.header.subtitle", "Manage your applications")}
</span>
</div>
<div className={shellLayoutClasses.headerActions}>
<LanguageSelector />
<button
type="button"
onClick={toggleTheme}
className={shellLayoutClasses.actionButton}
aria-label={t("ui.common.theme_toggle", "Toggle theme")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
{theme === "light"
? t("ui.common.theme_light", "Light")
: t("ui.common.theme_dark", "Dark")}
</button>
{isSessionExpiryEnabled ? (
<SessionStatusBadge
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : null}
<div className="relative" ref={profileMenuRef}>
<button
type="button"
onClick={() => setIsProfileMenuOpen((prev) => !prev)}
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
aria-haspopup="menu"
aria-expanded={isProfileMenuOpen}
aria-label={t(
"ui.dev.profile.menu_aria",
"Open account menu",
)}
>
<div className={shellLayoutClasses.profileInitial}>
{profileSummary.initial}
</div>
<div className="hidden min-w-0 text-left md:block">
<p className="truncate text-xs font-medium text-foreground">
{profileSummary.name}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{profileSummary.email}
</p>
</div>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${isProfileMenuOpen ? "rotate-180" : ""}`}
/>
</button>
{isProfileMenuOpen ? (
<div role="menu" className={shellLayoutClasses.profileMenu}>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")}
</p>
<div className={shellLayoutClasses.profileCard}>
<div>
<p className="truncate text-sm font-semibold text-foreground">
{profileSummary.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{profileSummary.email}
</p>
</div>
<div className="flex items-center pt-1">
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
{t(
`ui.admin.role.${displayRoleKey}`,
displayRoleKey.toUpperCase(),
)}
</span>
</div>
</div>
<div className={shellLayoutClasses.settingsCard}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t("ui.dev.session.auto_extend", "Session expiry")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled ? (
<SessionStatusText
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : (
t(
"ui.dev.session.disabled",
"Session expiry disabled",
)
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={isSessionExpiryEnabled}
onClick={handleSessionExpiryToggle}
className={[
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition",
isSessionExpiryEnabled ? "bg-primary" : "bg-muted",
].join(" ")}
>
<span
className={[
"inline-block h-5 w-5 rounded-full bg-white transition",
isSessionExpiryEnabled
? "translate-x-5"
: "translate-x-1",
].join(" ")}
/>
</button>
</div>
</div>
<button
type="button"
role="menuitem"
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
onClick={() => {
navigate("/profile");
setIsProfileMenuOpen(false);
}}
>
<UserIcon size={16} className="text-muted-foreground" />
<span>{t("ui.dev.profile.title", "My Profile")}</span>
</button>
<button
type="button"
role="menuitem"
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
onClick={handleLogout}
>
<LogOut size={16} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
) : null}
</div>
</div>
</div>
</header>
<main className={shellLayoutClasses.main}>
<Outlet />
</main>
</div>
<Toaster />
</div>
);
}
export default AppLayout;