feat(adminsite): session guards and the auth screens
Route-group layouts do the guarding. A customer session on /staff/* is redirected to its own home rather than shown a refusal -- there is nothing to tell them about. This is UX only: admin enforces the same boundary with RequireStaff/RequireCustomer and answers 404 rather than 403 for another account's data, which is the layer that actually matters. Signup carries the honeypot the backend expects and reports "check your email" rather than claiming an account exists, matching a backend that creates nothing until the link is opened. Buttons match site/'s .btn--solid and .btn--line, neutral border included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "./api";
|
||||
import { RequireKind } from "./session";
|
||||
|
||||
const replace = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace, push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const me = vi.fn();
|
||||
vi.mock("./api", async () => {
|
||||
const actual = await vi.importActual<typeof import("./api")>("./api");
|
||||
return { ...actual, api: { ...actual.api, me: () => me() } };
|
||||
});
|
||||
|
||||
function wrap(ui: React.ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("RequireKind", () => {
|
||||
beforeEach(() => replace.mockClear());
|
||||
|
||||
it("renders staff screens for a staff session", async () => {
|
||||
me.mockResolvedValue({ kind: "staff", email: "s@example.com" });
|
||||
wrap(<RequireKind kind="staff">operations</RequireKind>);
|
||||
expect(await screen.findByText("operations")).toBeInTheDocument();
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a customer session away from staff screens without telling it anything", async () => {
|
||||
me.mockResolvedValue({ kind: "customer", email: "c@example.com", account_id: "a1" });
|
||||
wrap(<RequireKind kind="staff">operations</RequireKind>);
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith("/"));
|
||||
expect(screen.queryByText("operations")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends an unauthenticated visitor to sign in", async () => {
|
||||
me.mockRejectedValue(new ApiError(401, "not signed in"));
|
||||
wrap(<RequireKind kind="customer">account</RequireKind>);
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith("/login"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { API_BASE, ApiError, NotConnected, api, type Session } from "./api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
|
||||
export function useSession() {
|
||||
const { data, error, isLoading } = useQuery<Session>({
|
||||
queryKey: ["me"],
|
||||
queryFn: api.me,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return { session: data, error, isLoading };
|
||||
}
|
||||
|
||||
/*
|
||||
* The route-group guard. This is UX, not security: admin enforces the same
|
||||
* boundary with RequireStaff/RequireCustomer and returns 404 rather than 403
|
||||
* for another account's data. A customer hitting a staff route is redirected
|
||||
* rather than shown a refusal, because there is nothing to tell them about.
|
||||
*/
|
||||
export function RequireKind({
|
||||
kind,
|
||||
children,
|
||||
}: {
|
||||
kind: Session["kind"];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { session, error, isLoading } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
if (session && session.kind !== kind) {
|
||||
router.replace(session.kind === "staff" ? "/staff" : "/");
|
||||
}
|
||||
}, [error, session, kind, router]);
|
||||
|
||||
if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
if (isLoading || !session || session.kind !== kind) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user