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 paste the address under it, then86type the code the app shows. The key is shown this once; from then on sign-in asks for the password87and a code.</p>88<p class="issue-meta mono">key: ${offered}</p>89<p class="issue-meta mono">${otpauthUri(offered, ctx.domain)}</p>90<form class="issues-form" method="post" action="/admin/account">91${formField(c)}<input type="hidden" name="action" value="totp-confirm">92<input type="hidden" name="secret" value="${offered}">93<label class="issues-label" for="account-code">code from the app</label>94<input class="issues-input mono" id="account-code" name="code" inputmode="numeric"95autocomplete="one-time-code" required autofocus>96<button class="issues-submit" type="submit">turn two-step sign-in on</button>97</form>`98 : ctx.config.totpSecret === undefined99 ? html`<p>Off. Sign-in asks for the password alone.</p>100<form method="post" action="/admin/account">101${formField(c)}<input type="hidden" name="action" value="totp-start">102<button class="issues-submit" type="submit">set up two-step sign-in</button>103</form>`104 : html`<p>On. Sign-in asks for the password and the code from your authenticator app.</p>105<form class="issues-form" method="post" action="/admin/account">106${formField(c)}<input type="hidden" name="action" value="totp-off">107${password("current_password", "password, to turn it off", "current-password")}108<p class="issue-meta mono">The password is also the way back in when the phone is lost.</p>109<button class="issues-submit" type="submit">turn it off</button>110</form>`;111 const body = html`${notice(error)}112<section class="repo-section"><h2 class="section-label mono">password</h2>113<form class="issues-form" method="post" action="/admin/account">114${formField(c)}<input type="hidden" name="action" value="password">115${password("current_password", "current password", "current-password")}116${password("password", `new password (at least ${MIN_PASSWORD} characters)`, "new-password")}117${password("confirm", "new password again", "new-password")}118<p class="issue-meta mono">Changing it signs out every other session.</p>119<button class="issues-submit" type="submit">change password</button>120</form></section>121<section class="repo-section"><h2 class="section-label mono">two-step sign-in</h2>122${twoStep}</section>123<section class="repo-section"><h2 class="section-label mono">sessions</h2>124<p>${plural(liveSessions(ctx), "session")} signed in, this one included. A session lasts 30 days.</p>125<form method="post" action="/admin/account">126${formField(c)}<input type="hidden" name="action" value="sessions">127<button class="issues-submit" type="submit">sign out everywhere else</button>128</form></section>129<section class="repo-section"><h2 class="section-label mono">domain</h2>130<form class="issues-form" method="post" action="/admin/account">131${formField(c)}<input type="hidden" name="action" value="domain">132<label class="issues-label" for="account-domain">domain</label>133<input class="issues-input mono" id="account-domain" name="domain" value="${ctx.domain}"134autocomplete="off" spellcheck="false" required>135<p class="issue-meta mono">Where Kepr is reached: pages, feeds and install.sh use it, and the console136accepts forms only from it. Point the new name at this server first, then save and sign in there.</p>137<button class="issues-submit" type="submit">save domain</button>138</form></section>`;139 return adminPage(c, ctx, "account", body);140}