feat(admin): add privileged token gate

This commit is contained in:
Arthur Belleville 2026-04-24 15:33:13 +02:00
parent 1c97113c67
commit 49cdd3b755
No known key found for this signature in database
7 changed files with 294 additions and 9 deletions

View file

@ -0,0 +1,49 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppRoutes from "../routes";
import { adminApi } from "../lib/api";
vi.mock("../lib/api", () => ({
adminApi: {
post: vi.fn(),
},
}));
describe("PrivilegedGate", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
it("exchanges a privileged token and enters the admin shell", async () => {
vi.mocked(adminApi.post).mockResolvedValue({
data: {
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
operatorEmail: "ops@xtablo.com",
operatorId: "operator-1",
role: "operator",
sessionToken: "admin-session-token",
},
});
render(
<MemoryRouter initialEntries={["/"]}>
<AppRoutes />
</MemoryRouter>
);
fireEvent.change(screen.getByLabelText(/access token/i), {
target: { value: "valid-access-token" },
});
fireEvent.click(screen.getByRole("button", { name: /unlock admin/i }));
await waitFor(() => {
expect(adminApi.post).toHaveBeenCalledWith("/admin/auth/exchange", {
accessToken: "valid-access-token",
});
});
expect(await screen.findByText(/operations home/i)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,55 @@
import { FormEvent, useState } from "react";
type PrivilegedGateProps = {
error?: string | null;
isPending?: boolean;
onUnlock: (accessToken: string) => Promise<unknown>;
};
export function PrivilegedGate({
error = null,
isPending = false,
onUnlock,
}: PrivilegedGateProps) {
const [accessToken, setAccessToken] = useState("");
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
await onUnlock(accessToken);
};
return (
<main className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-md rounded-3xl border border-border bg-card p-10 shadow-sm">
<p className="text-sm uppercase tracking-[0.25em] text-foreground/60">Internal Only</p>
<h1 className="mt-4 text-3xl font-semibold">Admin access token required</h1>
<p className="mt-3 text-sm text-foreground/70">
Normal XTablo login is not sufficient. Enter a privileged access token to unlock the
internal admin dashboard.
</p>
<form className="mt-8 space-y-4" onSubmit={handleSubmit}>
<label className="block text-sm font-medium" htmlFor="admin-access-token">
Access token
</label>
<input
id="admin-access-token"
className="w-full rounded-2xl border border-border bg-background px-4 py-3"
onChange={(event) => setAccessToken(event.target.value)}
value={accessToken}
/>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<button
className="w-full rounded-2xl bg-foreground px-4 py-3 text-background disabled:opacity-60"
disabled={isPending || accessToken.trim().length === 0}
type="submit"
>
{isPending ? "Unlocking..." : "Unlock Admin"}
</button>
</form>
</div>
</main>
);
}

View file

@ -0,0 +1,57 @@
import type { AdminSessionResponse } from "@xtablo/shared-types";
import { useEffect, useState } from "react";
import { adminApi } from "../lib/api";
import {
clearStoredAdminSession,
getStoredAdminSession,
storeAdminSession,
type StoredAdminSession,
} from "../lib/adminSession";
export function useAdminSession() {
const [session, setSession] = useState<StoredAdminSession | null>(() => getStoredAdminSession());
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setSession(getStoredAdminSession());
}, []);
const unlock = async (accessToken: string) => {
setIsPending(true);
setError(null);
try {
const response = await adminApi.post<AdminSessionResponse>("/admin/auth/exchange", {
accessToken,
});
storeAdminSession(response.data);
setSession(response.data);
return response.data;
} catch {
clearStoredAdminSession();
setSession(null);
setError("Invalid privileged access token");
return null;
} finally {
setIsPending(false);
}
};
const logout = () => {
clearStoredAdminSession();
setSession(null);
};
return {
error,
isAuthenticated: session !== null,
isPending,
logout,
operatorEmail: session?.operatorEmail ?? null,
role: session?.role ?? null,
session,
unlock,
};
}

View file

@ -0,0 +1,38 @@
import type { AdminRole } from "@xtablo/shared-types";
const ADMIN_SESSION_STORAGE_KEY = "xtablo-admin-session";
export type StoredAdminSession = {
expiresAt: string;
operatorEmail: string;
operatorId: string;
role: AdminRole;
sessionToken: string;
};
export function getStoredAdminSession() {
const rawSession = localStorage.getItem(ADMIN_SESSION_STORAGE_KEY);
if (!rawSession) {
return null;
}
try {
const parsedSession = JSON.parse(rawSession) as StoredAdminSession;
if (new Date(parsedSession.expiresAt).getTime() <= Date.now()) {
localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
return null;
}
return parsedSession;
} catch {
localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
return null;
}
}
export function storeAdminSession(session: StoredAdminSession) {
localStorage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify(session));
}
export function clearStoredAdminSession() {
localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
}

View file

@ -0,0 +1,5 @@
import { buildApi } from "@xtablo/shared";
const apiBaseUrl = import.meta.env.VITE_API_URL || "http://localhost:8080/api/v1";
export const adminApi = buildApi(apiBaseUrl);

View file

@ -1,19 +1,27 @@
import { Route, Routes } from "react-router-dom";
import { PrivilegedGate } from "./components/PrivilegedGate";
import { useAdminSession } from "./hooks/useAdminSession";
function PrivilegedGatePlaceholder() {
return (
<main className="flex min-h-screen items-center justify-center p-6">
<div className="rounded-3xl border border-border bg-card p-10 shadow-sm">
<p className="text-lg font-semibold">Admin access token required</p>
</div>
</main>
);
function AdminEntry() {
const { error, isAuthenticated, isPending, unlock } = useAdminSession();
if (isAuthenticated) {
return (
<main className="flex min-h-screen items-center justify-center p-6">
<div className="rounded-3xl border border-border bg-card p-10 shadow-sm">
<p className="text-lg font-semibold">Operations Home</p>
</div>
</main>
);
}
return <PrivilegedGate error={error} isPending={isPending} onUnlock={unlock} />;
}
export default function AppRoutes() {
return (
<Routes>
<Route path="*" element={<PrivilegedGatePlaceholder />} />
<Route path="*" element={<AdminEntry />} />
</Routes>
);
}

View file

@ -24,6 +24,79 @@ importers:
specifier: ^5.7.0
version: 5.9.3
apps/admin:
dependencies:
'@tanstack/react-query':
specifier: ^5.69.0
version: 5.90.5(react@19.0.0)
'@xtablo/shared':
specifier: workspace:*
version: link:../../packages/shared
'@xtablo/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
'@xtablo/ui':
specifier: workspace:*
version: link:../../packages/ui
react:
specifier: 19.0.0
version: 19.0.0
react-dom:
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
react-router-dom:
specifier: ^7.9.4
version: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
devDependencies:
'@biomejs/biome':
specifier: 2.2.5
version: 2.2.5
'@cloudflare/vite-plugin':
specifier: ^1.9.4
version: 1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.44.0(@cloudflare/workers-types@4.20260411.1))
'@tailwindcss/vite':
specifier: ^4.0.14
version: 4.1.15(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
'@testing-library/jest-dom':
specifier: ^6.6.3
version: 6.9.1
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@types/react':
specifier: 19.0.10
version: 19.0.10
'@types/react-dom':
specifier: 19.0.4
version: 19.0.4(@types/react@19.0.10)
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.7.0(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
happy-dom:
specifier: ^20.0.0
version: 20.0.7
tailwindcss:
specifier: ^4.0.14
version: 4.1.15
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
typescript:
specifier: ^5.7.0
version: 5.9.3
vite:
specifier: ^6.2.2
version: 6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)
vite-tsconfig-paths:
specifier: ^5.1.4
version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.12)(happy-dom@20.0.7)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)
wrangler:
specifier: ^4.24.3
version: 4.44.0(@cloudflare/workers-types@4.20260411.1)
apps/api:
dependencies:
'@aws-sdk/client-s3':