generated from nholtware/service-template-react
feat: passport-local authentication, story ownership
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ef23fdd187
commit
ae7bc89a82
9 changed files with 395 additions and 11 deletions
|
|
@ -79,10 +79,10 @@ describe("app", () => {
|
||||||
expect(await screen.findByText(/GET \/readyz/)).toBeInTheDocument();
|
expect(await screen.findByText(/GET \/readyz/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/"status": "ready"/)).toBeInTheDocument();
|
expect(screen.getByText(/"status": "ready"/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/"service": "svc"/)).toBeInTheDocument();
|
expect(screen.getByText(/"service": "svc"/)).toBeInTheDocument();
|
||||||
expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
|
// Layout also asks /auth/me who is signed in; that's not what's under test.
|
||||||
"/api/",
|
expect(
|
||||||
"/api/readyz",
|
fetchMock.mock.calls.map((c) => c[0]).filter((u) => u !== "/api/auth/me"),
|
||||||
]);
|
).toEqual(["/api/", "/api/readyz"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("status shows the error page when the backend is unreachable", async () => {
|
it("status shows the error page when the backend is unreachable", async () => {
|
||||||
|
|
|
||||||
45
src/lib/auth.ts
Normal file
45
src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { redirect } from "react-router";
|
||||||
|
import { ApiError, call } from "./stories";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchMe = () =>
|
||||||
|
call<{ user: User | null }>("GET", "/auth/me").then((r) => r.user);
|
||||||
|
export const login = (username: string, password: string) =>
|
||||||
|
call<{ user: User }>("POST", "/auth/login", { username, password }).then(
|
||||||
|
(r) => r.user,
|
||||||
|
);
|
||||||
|
export const register = (username: string, password: string) =>
|
||||||
|
call<{ user: User }>("POST", "/auth/register", { username, password }).then(
|
||||||
|
(r) => r.user,
|
||||||
|
);
|
||||||
|
export const logout = () => call<void>("POST", "/auth/logout");
|
||||||
|
|
||||||
|
/** Root loader: who am I? A failure just means "not signed in". */
|
||||||
|
export const authLoader = () => fetchMe().catch(() => null);
|
||||||
|
|
||||||
|
/** Run a route loader; a 401 sends the visitor to /login and back again. */
|
||||||
|
export async function withLogin<T>(
|
||||||
|
request: Request,
|
||||||
|
load: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await load();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
throw redirect(
|
||||||
|
`/login?next=${encodeURIComponent(url.pathname + url.search)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only same-site paths are valid post-login destinations (no open redirect). */
|
||||||
|
export function safeNext(next: string | null): string {
|
||||||
|
return next && /^\/(?![/\\])/.test(next) ? next : "/build";
|
||||||
|
}
|
||||||
|
|
@ -47,7 +47,7 @@ export class ApiError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function call<T>(
|
export async function call<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
|
|
@ -85,7 +85,10 @@ export const listStories = (mine: boolean) =>
|
||||||
`/stories${mine ? "?mine=1" : ""}`,
|
`/stories${mine ? "?mine=1" : ""}`,
|
||||||
).then((r) => r.stories);
|
).then((r) => r.stories);
|
||||||
export const getStory = (id: string) =>
|
export const getStory = (id: string) =>
|
||||||
call<{ story: Story; issues: Issue[] }>("GET", `/stories/${id}`);
|
call<{ story: Story; issues: Issue[]; ownedByMe: boolean }>(
|
||||||
|
"GET",
|
||||||
|
`/stories/${id}`,
|
||||||
|
);
|
||||||
export const createStory = (input: StoryInput) =>
|
export const createStory = (input: StoryInput) =>
|
||||||
call<{ story: Story; issues: Issue[] }>("POST", "/stories", input);
|
call<{ story: Story; issues: Issue[] }>("POST", "/stories", input);
|
||||||
export const saveStory = (id: string, input: StoryInput) =>
|
export const saveStory = (id: string, input: StoryInput) =>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import type { RouteObject } from "react-router";
|
import type { RouteObject } from "react-router";
|
||||||
|
import { authLoader, withLogin } from "./lib/auth";
|
||||||
import { Build, buildLoader } from "./routes/Build";
|
import { Build, buildLoader } from "./routes/Build";
|
||||||
import { BuildList, buildListLoader } from "./routes/BuildList";
|
import { BuildList, buildListLoader } from "./routes/BuildList";
|
||||||
import { ErrorPage } from "./routes/ErrorPage";
|
import { ErrorPage } from "./routes/ErrorPage";
|
||||||
import { Layout } from "./routes/Layout";
|
import { Layout } from "./routes/Layout";
|
||||||
import { Library, libraryLoader } from "./routes/Library";
|
import { Library, libraryLoader } from "./routes/Library";
|
||||||
|
import { Login } from "./routes/Login";
|
||||||
import { Play, playLoader } from "./routes/Play";
|
import { Play, playLoader } from "./routes/Play";
|
||||||
import { Status, statusLoader } from "./routes/Status";
|
import { Status, statusLoader } from "./routes/Status";
|
||||||
|
|
||||||
|
|
@ -12,12 +14,22 @@ export const routes: RouteObject[] = [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <Layout />,
|
element: <Layout />,
|
||||||
|
loader: authLoader,
|
||||||
errorElement: <ErrorPage />,
|
errorElement: <ErrorPage />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Library />, loader: libraryLoader },
|
{ index: true, element: <Library />, loader: libraryLoader },
|
||||||
{ path: "play/:id", element: <Play />, loader: playLoader },
|
{ path: "play/:id", element: <Play />, loader: playLoader },
|
||||||
{ path: "build", element: <BuildList />, loader: buildListLoader },
|
{ path: "login", element: <Login /> },
|
||||||
{ path: "build/:id", element: <Build />, loader: buildLoader },
|
{
|
||||||
|
path: "build",
|
||||||
|
element: <BuildList />,
|
||||||
|
loader: ({ request }) => withLogin(request, buildListLoader),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "build/:id",
|
||||||
|
element: <Build />,
|
||||||
|
loader: (args) => withLogin(args.request, () => buildLoader(args)),
|
||||||
|
},
|
||||||
{ path: "status", element: <Status />, loader: statusLoader },
|
{ path: "status", element: <Status />, loader: statusLoader },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
import {
|
||||||
|
Link,
|
||||||
|
redirect,
|
||||||
|
useLoaderData,
|
||||||
|
type LoaderFunctionArgs,
|
||||||
|
} from "react-router";
|
||||||
import { downloadJson, exportStory } from "../lib/exchange";
|
import { downloadJson, exportStory } from "../lib/exchange";
|
||||||
import {
|
import {
|
||||||
analyze,
|
analyze,
|
||||||
|
|
@ -20,7 +25,11 @@ import {
|
||||||
} from "../lib/stories";
|
} from "../lib/stories";
|
||||||
|
|
||||||
export async function buildLoader({ params }: LoaderFunctionArgs) {
|
export async function buildLoader({ params }: LoaderFunctionArgs) {
|
||||||
return (await getStory(params.id!)).story;
|
const { story, ownedByMe } = await getStory(params.id!);
|
||||||
|
// Someone else's published story: send them to the player, not an editor
|
||||||
|
// whose saves would 403.
|
||||||
|
if (!ownedByMe) throw redirect(`/play/${story.id}`);
|
||||||
|
return story;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Build() {
|
export function Build() {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,25 @@
|
||||||
import { NavLink, Outlet } from "react-router";
|
import {
|
||||||
|
Link,
|
||||||
|
NavLink,
|
||||||
|
Outlet,
|
||||||
|
useLoaderData,
|
||||||
|
useNavigate,
|
||||||
|
useRevalidator,
|
||||||
|
} from "react-router";
|
||||||
|
import { logout, type User } from "../lib/auth";
|
||||||
import { getConfig } from "../lib/config";
|
import { getConfig } from "../lib/config";
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
|
const user = useLoaderData() as User | null;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await logout().catch(() => {});
|
||||||
|
navigate("/");
|
||||||
|
await revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -13,6 +31,18 @@ export function Layout() {
|
||||||
<NavLink to="/build">Build</NavLink>
|
<NavLink to="/build">Build</NavLink>
|
||||||
<NavLink to="/status">Status</NavLink>
|
<NavLink to="/status">Status</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
<span className="who">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<span data-testid="whoami">{user.username}</span>{" "}
|
||||||
|
<button className="link" onClick={() => void signOut()}>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link to="/login">Log in</Link>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
|
||||||
72
src/routes/Login.tsx
Normal file
72
src/routes/Login.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useNavigate, useRevalidator, useSearchParams } from "react-router";
|
||||||
|
import { login, register, safeNext } from "../lib/auth";
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await (mode === "login" ? login : register)(username, password);
|
||||||
|
await revalidate();
|
||||||
|
navigate(safeNext(params.get("next")), { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLogin = mode === "login";
|
||||||
|
return (
|
||||||
|
<section className="editor auth">
|
||||||
|
<h1>{isLogin ? "Log in" : "Create an account"}</h1>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete={isLogin ? "current-password" : "new-password"}
|
||||||
|
required
|
||||||
|
minLength={isLogin ? undefined : 8}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p role="alert">{error}</p>}
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{isLogin ? "Log in" : "Sign up"}
|
||||||
|
</button>{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link"
|
||||||
|
onClick={() => {
|
||||||
|
setMode(isLogin ? "register" : "login");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLogin ? "Need an account? Sign up" : "Have an account? Log in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/routes/auth.test.tsx
Normal file
201
src/routes/auth.test.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { safeNext } from "../lib/auth";
|
||||||
|
import { routes } from "../routes";
|
||||||
|
|
||||||
|
vi.mock("@unleash/proxy-client-react", () => ({
|
||||||
|
FlagProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
useFlag: () => false,
|
||||||
|
useVariant: () => ({ name: "disabled", enabled: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Handler = (init?: RequestInit) => Response | Promise<Response>;
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status });
|
||||||
|
|
||||||
|
/** fetch stub dispatching on "METHOD /path"; unknown calls fail the test loudly. */
|
||||||
|
function mockApi(handlers: Record<string, Handler>) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url: string, init?: RequestInit) => {
|
||||||
|
const key = `${init?.method ?? "GET"} ${url.replace(/^\/api/, "")}`;
|
||||||
|
calls.push(key);
|
||||||
|
const h = handlers[key];
|
||||||
|
if (!h) throw new Error(`unexpected request: ${key}`);
|
||||||
|
return h(init);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAt(path: string) {
|
||||||
|
const router = createMemoryRouter(routes, { initialEntries: [path] });
|
||||||
|
render(<RouterProvider router={router} />);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.__APP_CONFIG__ = { API_BASE_URL: "/api", APP_NAME: "t" };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth UI", () => {
|
||||||
|
it("shows a Log in link when signed out and the username when signed in", async () => {
|
||||||
|
mockApi({
|
||||||
|
"GET /auth/me": () => json({ user: null }),
|
||||||
|
"GET /stories": () => json({ stories: [] }),
|
||||||
|
});
|
||||||
|
renderAt("/");
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("link", { name: "Log in" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
mockApi({
|
||||||
|
"GET /auth/me": () => json({ user: { id: "1", username: "alice" } }),
|
||||||
|
"GET /stories": () => json({ stories: [] }),
|
||||||
|
});
|
||||||
|
renderAt("/");
|
||||||
|
expect(await screen.findByTestId("whoami")).toHaveTextContent("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects /build to /login with next= when the API says 401", async () => {
|
||||||
|
mockApi({
|
||||||
|
"GET /auth/me": () => json({ user: null }),
|
||||||
|
"GET /stories?mine=1": () =>
|
||||||
|
json({ error: "authentication required" }, 401),
|
||||||
|
});
|
||||||
|
const router = renderAt("/build");
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Log in" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(router.state.location.pathname).toBe("/login");
|
||||||
|
expect(router.state.location.search).toBe("?next=%2Fbuild");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs in, returns to next, and shows server errors for bad credentials", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let signedIn = false;
|
||||||
|
mockApi({
|
||||||
|
"GET /auth/me": () =>
|
||||||
|
json({ user: signedIn ? { id: "1", username: "alice" } : null }),
|
||||||
|
"GET /stories?mine=1": () => json({ stories: [] }),
|
||||||
|
"POST /auth/login": (init) => {
|
||||||
|
const body = JSON.parse(String(init?.body));
|
||||||
|
if (body.password !== "correct horse") {
|
||||||
|
return json({ error: "invalid username or password" }, 401);
|
||||||
|
}
|
||||||
|
signedIn = true;
|
||||||
|
return json({ user: { id: "1", username: "alice" } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const router = renderAt("/login?next=%2Fbuild");
|
||||||
|
await user.type(await screen.findByLabelText("Username"), "alice");
|
||||||
|
await user.type(screen.getByLabelText("Password"), "wrong");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Log in" }));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
|
"invalid username or password",
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.clear(screen.getByLabelText("Password"));
|
||||||
|
await user.type(screen.getByLabelText("Password"), "correct horse");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Log in" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Your stories" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(router.state.location.pathname).toBe("/build");
|
||||||
|
expect(screen.getByTestId("whoami")).toHaveTextContent("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers a new account", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const calls = mockApi({
|
||||||
|
"GET /auth/me": () => json({ user: null }),
|
||||||
|
"GET /stories?mine=1": () => json({ stories: [] }),
|
||||||
|
"POST /auth/register": (init) => {
|
||||||
|
expect(JSON.parse(String(init?.body))).toEqual({
|
||||||
|
username: "bob",
|
||||||
|
password: "correct horse",
|
||||||
|
});
|
||||||
|
return json({ user: { id: "2", username: "bob" } }, 201);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderAt("/login");
|
||||||
|
await user.click(await screen.findByRole("button", { name: /Sign up/ }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Create an account" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await user.type(screen.getByLabelText("Username"), "bob");
|
||||||
|
await user.type(screen.getByLabelText("Password"), "correct horse");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Sign up" }));
|
||||||
|
await waitFor(() => expect(calls).toContain("POST /auth/register"));
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Your stories" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs out and returns to the library", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let signedIn = true;
|
||||||
|
mockApi({
|
||||||
|
"GET /auth/me": () =>
|
||||||
|
json({ user: signedIn ? { id: "1", username: "alice" } : null }),
|
||||||
|
"GET /stories": () => json({ stories: [] }),
|
||||||
|
"POST /auth/logout": () => {
|
||||||
|
signedIn = false;
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderAt("/");
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Log out" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("link", { name: "Log in" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("whoami")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends editors of someone else's published story to the player", async () => {
|
||||||
|
mockApi({
|
||||||
|
"GET /auth/me": () => json({ user: { id: "2", username: "bob" } }),
|
||||||
|
"GET /stories/s1": () =>
|
||||||
|
json({
|
||||||
|
story: {
|
||||||
|
id: "s1",
|
||||||
|
title: "Cave",
|
||||||
|
description: "",
|
||||||
|
published: true,
|
||||||
|
updatedAt: "",
|
||||||
|
doc: {
|
||||||
|
start: "a",
|
||||||
|
nodes: {
|
||||||
|
a: { title: "A", text: "hi", ending: "win", choices: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
issues: [],
|
||||||
|
ownedByMe: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const router = renderAt("/build/s1");
|
||||||
|
expect(await screen.findByText("hi")).toBeInTheDocument();
|
||||||
|
expect(router.state.location.pathname).toBe("/play/s1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("safeNext", () => {
|
||||||
|
it("allows same-site paths and rejects anything that could leave the site", () => {
|
||||||
|
expect(safeNext("/build/abc?x=1")).toBe("/build/abc?x=1");
|
||||||
|
for (const bad of [
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
"build",
|
||||||
|
"//evil.com",
|
||||||
|
"/\\evil.com",
|
||||||
|
"https://evil.com",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
]) {
|
||||||
|
expect(safeNext(bad)).toBe("/build");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -164,3 +164,15 @@ button.danger {
|
||||||
.choice-row select {
|
.choice-row select {
|
||||||
margin-top: 0 !important;
|
margin-top: 0 !important;
|
||||||
}
|
}
|
||||||
|
header .who {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
header .who button.link {
|
||||||
|
color: #f5e9d3;
|
||||||
|
}
|
||||||
|
.auth {
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
[role="alert"] {
|
||||||
|
color: #a31d1d;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue