From 49cdd3b7550f70b4cbba8d563942893386e8bc6d Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 24 Apr 2026 15:33:13 +0200 Subject: [PATCH] feat(admin): add privileged token gate --- .../src/components/PrivilegedGate.test.tsx | 49 +++++++++++++ apps/admin/src/components/PrivilegedGate.tsx | 55 ++++++++++++++ apps/admin/src/hooks/useAdminSession.ts | 57 +++++++++++++++ apps/admin/src/lib/adminSession.ts | 38 ++++++++++ apps/admin/src/lib/api.ts | 5 ++ apps/admin/src/routes.tsx | 26 ++++--- pnpm-lock.yaml | 73 +++++++++++++++++++ 7 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 apps/admin/src/components/PrivilegedGate.test.tsx create mode 100644 apps/admin/src/components/PrivilegedGate.tsx create mode 100644 apps/admin/src/hooks/useAdminSession.ts create mode 100644 apps/admin/src/lib/adminSession.ts create mode 100644 apps/admin/src/lib/api.ts diff --git a/apps/admin/src/components/PrivilegedGate.test.tsx b/apps/admin/src/components/PrivilegedGate.test.tsx new file mode 100644 index 0000000..cdd72a5 --- /dev/null +++ b/apps/admin/src/components/PrivilegedGate.test.tsx @@ -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( + + + + ); + + 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(); + }); +}); diff --git a/apps/admin/src/components/PrivilegedGate.tsx b/apps/admin/src/components/PrivilegedGate.tsx new file mode 100644 index 0000000..c22168f --- /dev/null +++ b/apps/admin/src/components/PrivilegedGate.tsx @@ -0,0 +1,55 @@ +import { FormEvent, useState } from "react"; + +type PrivilegedGateProps = { + error?: string | null; + isPending?: boolean; + onUnlock: (accessToken: string) => Promise; +}; + +export function PrivilegedGate({ + error = null, + isPending = false, + onUnlock, +}: PrivilegedGateProps) { + const [accessToken, setAccessToken] = useState(""); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + await onUnlock(accessToken); + }; + + return ( +
+
+

Internal Only

+

Admin access token required

+

+ Normal XTablo login is not sufficient. Enter a privileged access token to unlock the + internal admin dashboard. +

+ +
+ + setAccessToken(event.target.value)} + value={accessToken} + /> + + {error ?

{error}

: null} + + +
+
+
+ ); +} diff --git a/apps/admin/src/hooks/useAdminSession.ts b/apps/admin/src/hooks/useAdminSession.ts new file mode 100644 index 0000000..d52845e --- /dev/null +++ b/apps/admin/src/hooks/useAdminSession.ts @@ -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(() => getStoredAdminSession()); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setSession(getStoredAdminSession()); + }, []); + + const unlock = async (accessToken: string) => { + setIsPending(true); + setError(null); + + try { + const response = await adminApi.post("/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, + }; +} diff --git a/apps/admin/src/lib/adminSession.ts b/apps/admin/src/lib/adminSession.ts new file mode 100644 index 0000000..5eab8e1 --- /dev/null +++ b/apps/admin/src/lib/adminSession.ts @@ -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); +} diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts new file mode 100644 index 0000000..b562524 --- /dev/null +++ b/apps/admin/src/lib/api.ts @@ -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); diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index 3e30c96..a37f488 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -1,19 +1,27 @@ import { Route, Routes } from "react-router-dom"; +import { PrivilegedGate } from "./components/PrivilegedGate"; +import { useAdminSession } from "./hooks/useAdminSession"; -function PrivilegedGatePlaceholder() { - return ( -
-
-

Admin access token required

-
-
- ); +function AdminEntry() { + const { error, isAuthenticated, isPending, unlock } = useAdminSession(); + + if (isAuthenticated) { + return ( +
+
+

Operations Home

+
+
+ ); + } + + return ; } export default function AppRoutes() { return ( - } /> + } /> ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecad098..74a775a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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':