Add an optional site password for visitors

Settings → Privacy can set a password for the whole public site. Until a
visitor enters it, every public page shows a prompt over a blurred
made-up page, and images, preview clips and exports return 401; nothing
real is sent. Unlocking sets a 90-day cookie derived from the password
hash, so changing the password asks everyone again. The owner is never
asked while signed in. Wrong guesses are rate limited, and a locked site
sends noindex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 15:47:17 -07:00
1 parent 568162202b
commit 06097d3bca
11 files changed
+302 -9

No files matched your search

+58 -2
View File
@@ -1,4 +1,4 @@
// Settings: profile, appearance, integrations, backups, account.
// Settings: profile, appearance, privacy, integrations, backups, account.
import { h, icon, api, toast, busy, confirmDialog } from "./common.js";
const root = document.getElementById("settings");
@@ -172,6 +172,62 @@ function appearanceSection() {
);
}
// ---------- privacy ----------
function privacySection() {
const status = h("span", { class: "status" });
const pw = h("input", { type: "password", autocomplete: "new-password", minlength: 4, maxlength: 72 });
const show = h("label", { class: "check" }, h("input", {
type: "checkbox", onchange: (e) => { pw.type = e.target.checked ? "text" : "password"; },
}), "Show password");
const save = h("button", { type: "submit", class: "btn btn--primary" });
const off = h("button", { type: "button", class: "btn btn--ghost" }, "Remove password");
const render = () => {
const on = settings.sitePassword;
status.textContent = on ? "Password protected" : "Public";
status.className = "status " + (on ? "is-ok" : "is-off");
pw.placeholder = on ? "Type a new password to change it" : "At least 4 characters";
pw.required = !on;
save.textContent = on ? "Change password" : "Turn on";
off.hidden = !on;
};
const set = async (password, button, message) => {
busy(button, true);
try {
const res = await api("PUT", "/api/site-password", { password });
settings.sitePassword = res.sitePassword;
pw.value = "";
render();
toast(message);
} catch (err) {
toast(err.message, { error: true });
} finally {
busy(button, false);
}
};
const form = h("form", { class: "stack" },
h("div", { class: "integration-head" },
h("div", {}, h("h3", {}, icon("lock"), "Site password"),
h("p", {}, "Share your lists with friends and family without making them public. Visitors see a blurred page until they enter the password, and stay signed in on that device for 90 days. You never need it while you're signed in.")),
status),
field("Password", pw, "Changing it asks everyone for the new one."),
show,
h("div", { class: "settings-actions" }, off, save),
);
form.addEventListener("submit", (e) => {
e.preventDefault();
if (!pw.value) { toast("Type a password first", { error: true }); return; }
if (pw.value.length < 4) { toast("Use at least 4 characters", { error: true }); return; }
set(pw.value, save, settings.sitePassword ? "Site password changed" : "Your site now needs a password");
});
off.addEventListener("click", async () => {
if (!await confirmDialog({ title: "Make your site public?", message: "Anyone with the link will be able to see your lists.", confirm: "Remove password" })) return;
set("", off, "Your site is public again");
});
render();
return section("privacy", "Privacy", "Who can see your site.", form);
}
// ---------- integrations ----------
function secretInput(key, label, hintHTML) {
@@ -475,7 +531,7 @@ async function init() {
root.replaceChildren(h("p", { class: "alert alert--error" }, icon("alert"), err.message));
return;
}
root.replaceChildren(profileSection(), appearanceSection(), integrationsSection(), backupsSection(), accountSection());
root.replaceChildren(profileSection(), appearanceSection(), privacySection(), integrationsSection(), backupsSection(), accountSection());
root.removeAttribute("aria-busy");
if (location.hash) document.querySelector(location.hash)?.scrollIntoView();
}