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:
1 parent
568162202b
commit
06097d3bca
11 files changed
+302
-9
No files matched your search
@@ -571,6 +571,31 @@ input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px;
|
||||
.auth-back { display: inline-flex; align-items: center; gap: 6px; justify-content: center; color: var(--faint); font-size: 13px; text-decoration: none; margin-top: 4px; }
|
||||
.auth-back:hover { color: var(--text); }
|
||||
|
||||
/* ---------- site password gate ---------- */
|
||||
.page-gate main { position: relative; display: grid; place-items: center; min-height: 100dvh; padding: 32px 16px; overflow: hidden; }
|
||||
.gate-bg {
|
||||
position: fixed; inset: -40px; z-index: 0; pointer-events: none; overflow: hidden;
|
||||
filter: blur(18px) saturate(1.2); opacity: 0.85;
|
||||
}
|
||||
.gate-bg .container { padding-top: 96px; }
|
||||
.gate-sk-profile { display: flex; align-items: center; gap: 28px; padding-bottom: 40px; }
|
||||
.gate-sk-avatar { width: 112px; height: 112px; border-radius: 50%; background: var(--accent); flex: none; }
|
||||
.gate-sk-lines { display: flex; flex-direction: column; gap: 14px; flex: 1; }
|
||||
.gate-sk-lines i { display: block; height: 34px; width: min(420px, 70%); border-radius: 10px; background: var(--surface-2); }
|
||||
.gate-sk-lines i + i { height: 16px; width: min(280px, 50%); }
|
||||
.gate-sk-shelf { padding: 24px 0 36px; }
|
||||
.gate-sk-heading { display: block; width: 220px; height: 22px; border-radius: 8px; background: var(--surface-2); margin-bottom: 20px; }
|
||||
.gate-sk-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 18px; }
|
||||
.gate-sk-row span { aspect-ratio: 2 / 3; border-radius: var(--radius-art); background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 55%, var(--surface-2)), var(--surface-2)); }
|
||||
.gate-sk-row span:nth-child(3n+2) { background: linear-gradient(200deg, color-mix(in srgb, var(--accent) 25%, var(--surface)), color-mix(in srgb, var(--text) 18%, var(--surface-2))); }
|
||||
.gate-sk-row span:nth-child(3n) { background: linear-gradient(140deg, color-mix(in srgb, var(--text) 30%, var(--surface-2)), color-mix(in srgb, var(--accent) 40%, var(--bg))); }
|
||||
.gate-card {
|
||||
position: relative; z-index: 1;
|
||||
background: color-mix(in srgb, var(--surface) 86%, transparent);
|
||||
backdrop-filter: blur(12px) saturate(1.3); -webkit-backdrop-filter: blur(12px) saturate(1.3);
|
||||
}
|
||||
.gate-card h1 { overflow-wrap: anywhere; }
|
||||
|
||||
/* ---------- toasts ---------- */
|
||||
.toasts { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; width: max-content; max-width: calc(100vw - 32px); }
|
||||
.toast {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user