From 5438dfde3dfa512695021b3b93371089ded8242d Mon Sep 17 00:00:00 2001 From: dcruzeneil2 <247271309+dcruzeneil2@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:37:41 +0000 Subject: [PATCH] Add logout button to docs page --- app/docs/route.test.ts | 32 +++++++++++++++++++++++++ app/docs/route.ts | 4 +++- app/logout/route.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++ app/logout/route.ts | 22 ++++++++++++++++++ lib/auth/session.ts | 5 ++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 app/docs/route.test.ts create mode 100644 app/logout/route.test.ts create mode 100644 app/logout/route.ts diff --git a/app/docs/route.test.ts b/app/docs/route.test.ts new file mode 100644 index 0000000..e849edc --- /dev/null +++ b/app/docs/route.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + listDocs: vi.fn(), + listSharedDocs: vi.fn(), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: mocks.getSession })); +vi.mock("@/lib/docs/store", () => ({ + listDocs: mocks.listDocs, + listSharedDocs: mocks.listSharedDocs, +})); +vi.mock("@/lib/db", () => ({ query: vi.fn() })); + +import { GET } from "@/app/docs/route"; + +describe("GET /docs", () => { + beforeEach(() => { + mocks.getSession.mockResolvedValue({ id: 1, email: "user@example.com", user_id: 1 }); + mocks.listDocs.mockResolvedValue([]); + mocks.listSharedDocs.mockResolvedValue([]); + }); + + it("shows a logout button for the current session", async () => { + const res = await GET(new Request("https://justhtml.sh/docs")); + const html = await res.text(); + + expect(html).toContain('
'); + expect(html).toContain(''); + }); +}); diff --git a/app/docs/route.ts b/app/docs/route.ts index d49ab10..5e8359b 100644 --- a/app/docs/route.ts +++ b/app/docs/route.ts @@ -35,6 +35,8 @@ const VARIANT_C_STYLE = ` .row a.title { font-weight: 700; } .row .tail { color: #888; } .row .tail a { color: #888; } + .session { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.75rem; } + .session form { margin: 0; } `; // The paste-to-agent prompt and copy button, reused verbatim from the homepage @@ -203,7 +205,7 @@ export async function GET(req: Request): Promise { const sections: string[] = []; sections.push(VARIANT_C_STYLE); sections.push( - `
Signed in as ${esc(email)}. See bookmarks.
` + `
Signed in as ${esc(email)}. See bookmarks.
` ); if (hasAccount) { sections.push(ownedSection(owned)); diff --git a/app/logout/route.test.ts b/app/logout/route.test.ts new file mode 100644 index 0000000..3cbbcdc --- /dev/null +++ b/app/logout/route.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { sha256Hex } from "@/lib/auth/tokens"; + +const mocks = vi.hoisted(() => ({ query: vi.fn() })); +vi.mock("@/lib/db", () => ({ query: mocks.query })); + +import { POST } from "@/app/logout/route"; + +function request(cookie?: string, origin = "https://justhtml.sh"): Request { + const headers = new Headers({ origin }); + if (cookie) headers.set("cookie", cookie); + return new Request("https://justhtml.sh/logout", { method: "POST", headers }); +} + +describe("POST /logout", () => { + beforeEach(() => { + mocks.query.mockReset(); + mocks.query.mockResolvedValue({ rows: [] }); + }); + + it("revokes the session, clears the cookie, and redirects to login", async () => { + const res = await POST(request("other=value; jh_sess=sess_test")); + + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("/login"); + expect(res.headers.get("set-cookie")).toBe( + "jh_sess=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0" + ); + expect(mocks.query).toHaveBeenCalledWith( + expect.stringContaining("UPDATE sessions SET revoked_at = now()"), + [sha256Hex("sess_test")] + ); + }); + + it("still clears a stale or missing session cookie", async () => { + const res = await POST(request()); + + expect(res.status).toBe(303); + expect(res.headers.get("set-cookie")).toContain("Max-Age=0"); + expect(mocks.query).not.toHaveBeenCalled(); + }); + + it("rejects cross-site requests", async () => { + const res = await POST(request("jh_sess=sess_test", "https://example.com")); + + expect(res.status).toBe(403); + expect(res.headers.has("set-cookie")).toBe(false); + expect(mocks.query).not.toHaveBeenCalled(); + }); +}); diff --git a/app/logout/route.ts b/app/logout/route.ts new file mode 100644 index 0000000..555abfe --- /dev/null +++ b/app/logout/route.ts @@ -0,0 +1,22 @@ +import { clearSessionCookieHeader, readSessionCookie } from "@/lib/auth/session"; +import { originOk } from "@/lib/auth/request"; +import { sha256Hex } from "@/lib/auth/tokens"; +import { query } from "@/lib/db"; +import { redirect } from "@/lib/page"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request): Promise { + if (!originOk(req)) return new Response("Forbidden.", { status: 403 }); + + const token = readSessionCookie(req); + if (token?.startsWith("sess_")) { + await query( + `UPDATE sessions SET revoked_at = now() + WHERE token_hash = $1 AND revoked_at IS NULL`, + [sha256Hex(token)] + ); + } + + return redirect("/login", { "Set-Cookie": clearSessionCookieHeader() }); +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts index 2c63ed7..d6ac72a 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -103,6 +103,11 @@ export function sessionCookieHeader(token: string): string { return `${SESSION_COOKIE}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_S}`; } +/** Expire the session cookie in the current browser. */ +export function clearSessionCookieHeader(): string { + return `${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`; +} + /** * Set-Cookie for the login-intent marker — set on the browser that just * requested a magic link. Scoped to /login so it rides the emailed