diff --git a/adminfront/package-lock.json b/adminfront/package-lock.json
index a1198055..59bfe9e3 100644
--- a/adminfront/package-lock.json
+++ b/adminfront/package-lock.json
@@ -17,6 +17,7 @@
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
"@tanstack/react-query-devtools": "^5.66.8",
+ "@tanstack/react-virtual": "^3.13.24",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -3290,6 +3291,33 @@
"react": "^18 || ^19"
}
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.24",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
+ "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.14.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
+ "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
diff --git a/adminfront/package.json b/adminfront/package.json
index 742cba61..b687f930 100644
--- a/adminfront/package.json
+++ b/adminfront/package.json
@@ -28,6 +28,7 @@
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
"@tanstack/react-query-devtools": "^5.66.8",
+ "@tanstack/react-virtual": "^3.13.24",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/adminfront/src/app/routes.test.tsx b/adminfront/src/app/routes.test.tsx
index 86782fde..08d10367 100644
--- a/adminfront/src/app/routes.test.tsx
+++ b/adminfront/src/app/routes.test.tsx
@@ -21,4 +21,26 @@ describe("admin routes", () => {
expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
});
+
+ it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
+ const rootRoute = adminRoutes.find((route) => route.path === "/");
+ const protectedShellRoute = rootRoute?.children?.[0];
+
+ expect(getRouteElementName(rootRoute?.element)).toBe("AuthGuard");
+ expect(getRouteElementName(protectedShellRoute?.element)).toBe("AppLayout");
+ expect(protectedShellRoute?.children?.at(0)?.index).toBe(true);
+ });
});
+
+function getRouteElementName(element: unknown) {
+ if (
+ typeof element === "object" &&
+ element !== null &&
+ "type" in element &&
+ typeof element.type === "function"
+ ) {
+ return element.type.name;
+ }
+
+ return undefined;
+}
diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx
index 9e1289a5..df3df402 100644
--- a/adminfront/src/app/routes.tsx
+++ b/adminfront/src/app/routes.tsx
@@ -5,6 +5,7 @@ import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
+import AuthGuard from "../features/auth/AuthGuard";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
@@ -34,34 +35,39 @@ export const adminRoutes: RouteObject[] = [
},
{
path: "/",
- element: ,
+ element: ,
children: [
- { index: true, element: },
- { path: "audit-logs", element: },
- { path: "auth", element: },
- { path: "users", element: },
- { path: "users/new", element: },
- { path: "users/:id", element: },
- { path: "tenants", element: },
- { path: "tenants/new", element: },
{
- path: "tenants/:tenantId",
- element: ,
+ element: ,
children: [
- { index: true, element: },
- { path: "permissions", element: },
- { path: "organization", element: },
- { path: "schema", element: },
- { path: "worksmobile", element: },
+ { index: true, element: },
+ { path: "audit-logs", element: },
+ { path: "auth", element: },
+ { path: "users", element: },
+ { path: "users/new", element: },
+ { path: "users/:id", element: },
+ { path: "tenants", element: },
+ { path: "tenants/new", element: },
+ {
+ path: "tenants/:tenantId",
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: "permissions", element: },
+ { path: "organization", element: },
+ { path: "schema", element: },
+ { path: "worksmobile", element: },
+ ],
+ },
+ {
+ path: "tenants/:tenantId/organization/:id",
+ element: ,
+ },
+ { path: "api-keys", element: },
+ { path: "api-keys/new", element: },
+ { path: "system/projections/users", element: },
],
},
- {
- path: "tenants/:tenantId/organization/:id",
- element: ,
- },
- { path: "api-keys", element: },
- { path: "api-keys/new", element: },
- { path: "system/projections/users", element: },
],
},
];
diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx
index 3a9b7895..7f8d894a 100644
--- a/adminfront/src/components/layout/AppLayout.tsx
+++ b/adminfront/src/components/layout/AppLayout.tsx
@@ -20,6 +20,7 @@ import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
+ type ShellTranslator,
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
@@ -53,6 +54,48 @@ const staticNavItems: NavItem[] = [
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
+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 (
+
+ {sessionStatus.text}
+
+ );
+}
+
+function SessionStatusText(props: SessionStatusProps) {
+ const sessionStatus = useSessionStatus(props);
+
+ return <>{sessionStatus.text}>;
+}
+
function AppLayout() {
const auth = useAuth();
const location = useLocation();
@@ -76,17 +119,6 @@ function AppLayout() {
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
);
- const [nowMs, setNowMs] = useState(() => Date.now());
-
- useEffect(() => {
- const timer = window.setInterval(() => {
- setNowMs(Date.now());
- }, 1000);
- return () => {
- window.clearInterval(timer);
- };
- }, []);
-
const {
data: profile,
isLoading: isProfileLoading,
@@ -396,12 +428,6 @@ function AppLayout() {
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
});
const profileRoleKey = mockRoleOverride || profile?.role || "user";
- const sessionStatus = buildShellSessionStatus({
- expiresAtSec: auth.user?.expires_at,
- nowMs,
- t,
- });
-
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
@@ -525,14 +551,10 @@ function AppLayout() {
: t("ui.common.theme_dark", "Dark")}
{isSessionExpiryEnabled ? (
-
- {sessionStatus.text}
-
+
) : null}