1import type { Context, Hono } from "hono";2import { html } from "../pages/html.ts";3import { plural } from "../pages/layout.ts";4import { isDomain, saveConfig } from "../server/config.ts";5import { keepState } from "../server/db.ts";6import { endOtherSessions, liveSessions } from "../server/login.ts";7import { isSecret, newSecret, otpauthUri, stepFor } from "../server/totp.ts";8import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";910export const MIN_PASSWORD = 12;111213141516export function registerAccount(app: Hono, ctx: AdminContext): void {17 app.get("/admin/account", (c) => c.html(accountPage(c, ctx, "")));1819 app.post("/admin/account", async (c) => {20 const fields = await textFields(c);21 const refuse = (error: string) => c.html(accountPage(c, ctx, error), 400);22 const save = async (change: Partial<typeof ctx.config>) => {23 await saveConfig(ctx.configPath, { ...ctx.config, ...change });24 Object.assign(ctx.config, change);25 };26 if (fields.action === "password") {27 const password = fields.password ?? "";28 const current = fields.current_password ?? "";29 if (!(await Bun.password.verify(current, ctx.config.adminPasswordHash))) {30 return refuse("The current password is not right.");31 }32 if (password.length < MIN_PASSWORD) {33 return refuse(`The new password needs at least ${MIN_PASSWORD} characters.`);34 }35 if (password !== fields.confirm) return refuse("The two new passwords differ.");36 await save({ adminPasswordHash: await Bun.password.hash(password, "argon2id") });37 38 endOtherSessions(ctx, c);39 } else if (fields.action === "totp-start") {40 return c.html(accountPage(c, ctx, "", newSecret()));41 } else if (fields.action === "totp-confirm") {42 const secret = fields.secret ?? "";43 const step = isSecret(secret)44 ? stepFor(secret, (fields.code ?? "").trim(), Math.floor(Date.now() / 1000))45 : null;46 if (step === null) {47 return c.html(accountPage(c, ctx, "That code did not match. Set it up again.", ""), 400);48 }49 50 keepState(ctx.kepr, "totp_step", String(step));51 await save({ totpSecret: secret });52 } else if (fields.action === "totp-off") {53 const current = fields.current_password ?? "";54 if (!(await Bun.password.verify(current, ctx.config.adminPasswordHash))) {55 return refuse("The current password is not right.");56 }57 keepState(ctx.kepr, "totp_step", null);58 await save({ totpSecret: undefined });59 } else if (fields.action === "sessions") {60 endOtherSessions(ctx, c);61 } else if (fields.action === "domain") {62 const domain = (fields.domain ?? "").trim().toLowerCase();63 if (!isDomain(domain)) {64 return refuse(65 "The domain is a bare hostname, such as code.example.com, with an optional :port.",66 );67 }68 await save({ domain });69 } else {70 return refuse("Unknown action.");71 }72 return c.redirect("/admin/account?saved=1", 303);73 });74}7576function accountPage(c: Context, ctx: AdminContext, error: string, offered = ""): string {77 const password = (name: string, label: string, autocomplete: string) =>78 html`<label class="issues-label" for="account-${name}">${label}</label>79<input class="issues-input" id="account-${name}" name="${name}" type="password"80autocomplete="${autocomplete}" required>`;81 82 83 const twoStep =84 offered !== ""85 ? html`<p>Add this key to an authenticator app by hand, or open the address below on a phone86that has one, then type the code the app shows. The key is shown this once; from then on sign-in87asks for the password and a code.</p>88<p class="issue-meta mono">key: ${offered}89<button type="button" class="copy-btn" data-copy="${offered}" hidden>copy key</button></p>90<p class="issue-meta mono"><a href="${otpauthUri(offered, ctx.domain)}">open in an authenticator app</a>91<button type="button" class="copy-btn" data-copy="${otpauthUri(offered, ctx.domain)}" hidden>copy the92setup address</button></p>93<form class="issues-form" method="post" action="/admin/account">94${formField(c)}<input type="hidden" name="action" value="totp-confirm">95<input type="hidden" name="secret" value="${offered}">96<label class="issues-label" for="account-code">code from the app</label>97<input class="issues-input mono" id="account-code" name="code" inputmode="numeric"98autocomplete="one-time-code" required autofocus>99<button class="issues-submit" type="submit">turn two-step sign-in on</button>100</form>`101 : ctx.config.totpSecret === undefined102 ? html`<p>Off. Sign-in asks for the password alone.</p>103<form method="post" action="/admin/account">104${formField(c)}<input type="hidden" name="action" value="totp-start">105<button class="issues-submit" type="submit">set up two-step sign-in</button>106</form>`107 : html`<p>On. Sign-in asks for the password and the code from your authenticator app.</p>108<form class="issues-form" method="post" action="/admin/account">109${formField(c)}<input type="hidden" name="action" value="totp-off">110${password("current_password", "password, to turn it off", "current-password")}111<p class="issue-meta mono">The password is also the way back in when the phone is lost.</p>112<button class="issues-submit" type="submit">turn it off</button>113</form>`;114 const body = html`${notice(error)}115<section class="repo-section"><h2 class="section-label mono">password</h2>116<form class="issues-form" method="post" action="/admin/account">117${formField(c)}<input type="hidden" name="action" value="password">118${password("current_password", "current password", "current-password")}119${password("password", `new password (at least ${MIN_PASSWORD} characters)`, "new-password")}120${password("confirm", "new password again", "new-password")}121<p class="issue-meta mono">Changing it signs out every other session.</p>122<button class="issues-submit" type="submit">change password</button>123</form></section>124<section class="repo-section"><h2 class="section-label mono">two-step sign-in</h2>125${twoStep}</section>126<section class="repo-section"><h2 class="section-label mono">sessions</h2>127<p>${plural(liveSessions(ctx), "session")} signed in, this one included. A session lasts 30 days.</p>128<form method="post" action="/admin/account">129${formField(c)}<input type="hidden" name="action" value="sessions">130<button class="issues-submit" type="submit">sign out everywhere else</button>131</form></section>132<section class="repo-section"><h2 class="section-label mono">domain</h2>133<form class="issues-form" method="post" action="/admin/account">134${formField(c)}<input type="hidden" name="action" value="domain">135<label class="issues-label" for="account-domain">domain</label>136<input class="issues-input mono" id="account-domain" name="domain" value="${ctx.domain}"137autocomplete="off" spellcheck="false" required>138<p class="issue-meta mono">Where Kepr is reached: pages, feeds and install.sh use it, and the console139accepts forms only from it. Point the new name at this server first, then save and sign in there.</p>140<button class="issues-submit" type="submit">save domain</button>141</form></section>`;142 return adminPage(c, ctx, "account", body);143}