feat(admin): add privileged token gate
This commit is contained in:
parent
1c97113c67
commit
49cdd3b755
7 changed files with 294 additions and 9 deletions
49
apps/admin/src/components/PrivilegedGate.test.tsx
Normal file
49
apps/admin/src/components/PrivilegedGate.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
55
apps/admin/src/components/PrivilegedGate.tsx
Normal file
55
apps/admin/src/components/PrivilegedGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/admin/src/hooks/useAdminSession.ts
Normal file
57
apps/admin/src/hooks/useAdminSession.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
38
apps/admin/src/lib/adminSession.ts
Normal file
38
apps/admin/src/lib/adminSession.ts
Normal 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);
|
||||
}
|
||||
5
apps/admin/src/lib/api.ts
Normal file
5
apps/admin/src/lib/api.ts
Normal 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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Reference in a new issue