8c1ff73fd409

show protected log out in the signed-in navbar (v3.0.117)

time
2026-09-29 20:26 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
gpt-6-sol
session
navbar login state
parent
7120956a7fa3
5 files package.json+1 −1public/css/kepr.css+16 −0public/js/kepr.js+22 −0tests/console.test.ts+1 −1tests/navbar.test.ts+55 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.116",3+	"version": "3.0.117",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+16 −0
@@ -132,6 +132,22 @@132132 .nav-links a {133133 	color: var(--text-secondary);134134 }135+.nav-logout {136+	margin: 0;137+}138+.nav-logout button {139+	border: 0;140+	padding: 0;141+	background: none;142+	color: var(--text-secondary);143+	font: inherit;144+	letter-spacing: inherit;145+	cursor: pointer;146+}147+.nav-logout button:hover {148+	text-decoration: underline;149+	text-decoration-color: var(--accent);150+}135151 136152 main {137153 	max-width: 980px;

view file

public/js/kepr.js+22 −0
@@ -608,6 +608,27 @@608608 	return open;609609 }610610 611+async function navbarLogin() {612+	const link = document.querySelector('.nav-links a[href^="/sign-in"]');613+	if (!link || !/(^|;\s*)kepr_editor=1(?:;|$)/.test(document.cookie)) return;614+	try {615+		const response = await fetch("/admin", { cache: "no-store" });616+		if (!response.ok) return;617+		const doc = new DOMParser().parseFromString(await response.text(), "text/html");618+		const token = doc.querySelector('form[action="/admin/sign-out"] input[name="form"]');619+		if (!token) return;620+		const form = element("form", "nav-logout");621+		form.method = "post";622+		form.action = "/admin/sign-out";623+		const button = element("button", "", "log out");624+		button.type = "submit";625+		form.append(token, button);626+		link.replaceWith(form);627+	} catch (error) {628+		console.error("Could not check navbar sign-in state", error);629+	}630+}631+611632 function element(tag, className, text) {612633 	const node = document.createElement(tag);613634 	node.className = className;@@ -880,6 +901,7 @@880901 saveLists();881902 atlasPicker();882903 repoEditor();904+navbarLogin();883905 lineRanges();884906 atlas();885907 hexGlow();

view file

tests/console.test.ts+1 −1
@@ -375,7 +375,7 @@375375 		const own = { ...settings, support_page: "## Help\n\nMail **me**." };376376 		expect((await operator.post("/admin/site", own)).status).toBe(303);377377 		const support = await text("/support");378-		expect(support).toContain("<h2 id=\"help\">Help</h2>\n<p>Mail <strong>me</strong>.</p>");378+		expect(support).toContain('<h2 id="help">Help</h2>\n<p>Mail <strong>me</strong>.</p>');379379 		expect(support).not.toBe(shipped);380380 		const { support: _, ...off } = own;381381 		expect((await operator.post("/admin/site", off)).status).toBe(303);

view file

tests/navbar.test.ts+55 −0
@@ -0,0 +1,55 @@1+import { expect, test } from "bun:test";2+import { runInNewContext } from "node:vm";3+4+const script = await Bun.file(new URL("../public/js/kepr.js", import.meta.url)).text();5+const navbar = script.slice(6+	script.indexOf("async function navbarLogin()"),7+	script.indexOf("// Signed in, a repo page"),8+);9+10+test("navbar offers protected log out only after confirming a live session", async () => {11+	for (const [cookie, status, logout] of [12+		["", 200, false],13+		["kepr_editor=1", 401, false],14+		["kepr_editor=1", 200, true],15+	] as const) {16+		const nodes: Record<string, unknown>[] = [];17+		let replacement: unknown;18+		const token = { name: "form", value: "session-form-token" };19+		const document = {20+			cookie,21+			querySelector: () => ({22+				replaceWith: (node: unknown) => {23+					replacement = node;24+				},25+			}),26+			createElement: (tag: string) => {27+				const node: Record<string, unknown> = {28+					tag,29+					append: (...children: unknown[]) => {30+						node.children = children;31+					},32+				};33+				nodes.push(node);34+				return node;35+			},36+		};37+		await runInNewContext(`${navbar}\nnavbarLogin()`, {38+			document,39+			fetch: async () => ({ ok: status === 200, text: async () => "admin page" }),40+			DOMParser: class {41+				parseFromString() {42+					return { querySelector: () => token };43+				}44+			},45+			console,46+		});47+		expect(Boolean(replacement)).toBe(logout);48+		if (logout) {49+			expect(nodes[0]?.method).toBe("post");50+			expect(nodes[0]?.action).toBe("/admin/sign-out");51+			expect(nodes[0]?.children).toEqual([token, nodes[1]]);52+			expect(nodes[1]?.textContent).toBe("log out");53+		}54+	}55+});

view file