import type { Context, Hono } from "hono"; import { html } from "../pages/html.ts"; import { plural } from "../pages/layout.ts"; import { isDomain, saveConfig } from "../server/config.ts"; import { keepState } from "../server/db.ts"; import { endOtherSessions, liveSessions } from "../server/login.ts"; import { isSecret, newSecret, otpauthUri, stepFor } from "../server/totp.ts"; import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts"; export const MIN_PASSWORD = 12; /** * The Account section (June, 2026-09-30): the admin password, the sessions signed in, and the * domain, each written to kepr.toml by Kepr and applied without a restart. */ export function registerAccount(app: Hono, ctx: AdminContext): void { app.get("/admin/account", (c) => c.html(accountPage(c, ctx, ""))); app.post("/admin/account", async (c) => { const fields = await textFields(c); const refuse = (error: string) => c.html(accountPage(c, ctx, error), 400); const save = async (change: Partial) => { await saveConfig(ctx.configPath, { ...ctx.config, ...change }); Object.assign(ctx.config, change); }; if (fields.action === "password") { const password = fields.password ?? ""; const current = fields.current_password ?? ""; if (!(await Bun.password.verify(current, ctx.config.adminPasswordHash))) { return refuse("The current password is not right."); } if (password.length < MIN_PASSWORD) { return refuse(`The new password needs at least ${MIN_PASSWORD} characters.`); } if (password !== fields.confirm) return refuse("The two new passwords differ."); await save({ adminPasswordHash: await Bun.password.hash(password, "argon2id") }); // Whoever knew the old password is signed out with it. endOtherSessions(ctx, c); } else if (fields.action === "totp-start") { return c.html(accountPage(c, ctx, "", newSecret())); } else if (fields.action === "totp-confirm") { const secret = fields.secret ?? ""; const step = isSecret(secret) ? stepFor(secret, (fields.code ?? "").trim(), Math.floor(Date.now() / 1000)) : null; if (step === null) { return c.html(accountPage(c, ctx, "That code did not match. Set it up again.", ""), 400); } // The code that set it up is spent, so the same one cannot also sign in (ยง24). keepState(ctx.kepr, "totp_step", String(step)); await save({ totpSecret: secret }); } else if (fields.action === "totp-off") { const current = fields.current_password ?? ""; if (!(await Bun.password.verify(current, ctx.config.adminPasswordHash))) { return refuse("The current password is not right."); } keepState(ctx.kepr, "totp_step", null); await save({ totpSecret: undefined }); } else if (fields.action === "sessions") { endOtherSessions(ctx, c); } else if (fields.action === "domain") { const domain = (fields.domain ?? "").trim().toLowerCase(); if (!isDomain(domain)) { return refuse( "The domain is a bare hostname, such as code.example.com, with an optional :port.", ); } await save({ domain }); } else { return refuse("Unknown action."); } return c.redirect("/admin/account?saved=1", 303); }); } function accountPage(c: Context, ctx: AdminContext, error: string, offered = ""): string { const password = (name: string, label: string, autocomplete: string) => html` `; // Setting up keeps the offered secret in the form: the code that confirms it proves whoever // typed it holds it, so a tampered field can only fail. const twoStep = offered !== "" ? html`

Add this key to an authenticator app by hand, or paste the address under it, then type the code the app shows. The key is shown this once; from then on sign-in asks for the password and a code.

key: ${offered}

${otpauthUri(offered, ctx.domain)}

${formField(c)}
` : ctx.config.totpSecret === undefined ? html`

Off. Sign-in asks for the password alone.

${formField(c)}
` : html`

On. Sign-in asks for the password and the code from your authenticator app.

${formField(c)} ${password("current_password", "password, to turn it off", "current-password")}

The password is also the way back in when the phone is lost.

`; const body = html`${notice(error)}
${formField(c)} ${password("current_password", "current password", "current-password")} ${password("password", `new password (at least ${MIN_PASSWORD} characters)`, "new-password")} ${password("confirm", "new password again", "new-password")}

Changing it signs out every other session.

${twoStep}

${plural(liveSessions(ctx), "session")} signed in, this one included. A session lasts 30 days.

${formField(c)}
${formField(c)}

Where Kepr is reached: pages, feeds and install.sh use it, and the console accepts forms only from it. Point the new name at this server first, then save and sign in there.

`; return adminPage(c, ctx, "account", body); }