From b610b1817ae0203bb9c683a5073a7194e271318d Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Sun, 27 Sep 2026 22:04:59 +0200 Subject: [PATCH] Fold the account page's ntfy and password forms behind disclosures Both sat open by default, competing with the rest of the page for attention on every visit even though most visits need neither. Team's rota already has the same problem for its bulk-assign form and solves it with a native
/ disclosure, styled generically in app.css; this reuses that idiom rather than inventing a JS toggle. Each section now shows a one-line status (the topic, or whether a password is set) with the actual form folded under a summary naming the action ("Set a topic" / "Change topic", "Set a password" / "Change password"). A successful save closes the fold and confirms with a toast, since the point of folding is that a saved form goes back to being just a status line; a validation or API error keeps the fold open and shows inline, next to the field it's about. The password section's heading no longer says "Change password" or "Set a password" itself, since that verb now lives on the summary; it just says "Password", matching the existing SSO-off case. --- internal/web/static/app.css | 2 + internal/web/static/js/account.js | 94 +++++++++++++++++-------------- 2 files changed, 55 insertions(+), 41 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index fd75e93..6090c77 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -639,6 +639,8 @@ details[open] > summary { margin-bottom: 8px; } .account-name { font-size: 18px; font-weight: 750; } .account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; } .pw-form { display: grid; gap: 12px; padding: 16px; } +.account-fold { border-top: 1px solid var(--border); padding-top: 12px; } +.account-fold .stacked-form { margin-top: 8px; } .form-ok { margin: 0; padding: 10px 12px; background: var(--ok-soft); color: var(--ok); diff --git a/internal/web/static/js/account.js b/internal/web/static/js/account.js index 0708b80..80a7bb5 100644 --- a/internal/web/static/js/account.js +++ b/internal/web/static/js/account.js @@ -43,9 +43,12 @@ function render() { // // The topic is the whole address — the server it is published to is the // install's one ntfy, set in the deployment and not something a user picks. +function notifyStatus(topic) { + return topic ? `Topic: ${topic}` : 'No topic set — pages go to the team’s fallback topic.'; +} + function notifyForm(user) { const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); - const ok = h('p', { class: 'form-ok', role: 'status', hidden: true }); const topic = h('input', { name: 'ntfy_topic', type: 'text', autocomplete: 'off', autocapitalize: 'none', spellcheck: false, @@ -54,29 +57,7 @@ function notifyForm(user) { }); const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' }); - // Only offered once a topic is saved: the test publishes to whatever the - // server has stored, not to whatever is half-typed in the field. - const test = h('button', { - class: 'btn', type: 'button', text: 'Send a test push', - hidden: !user.ntfy_topic, - onclick: async () => { - err.hidden = true; - ok.hidden = true; - test.disabled = true; - try { - await api.testNotification(); - ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.'; - ok.hidden = false; - } catch (ex) { - err.textContent = ex.message; - err.hidden = false; - } finally { - test.disabled = false; - } - }, - }); - - const form = h('form', { class: 'card pw-form' }, + const form = h('form', { class: 'stacked-form' }, h('label', {}, h('span', { text: 'ntfy topic' }), topic), @@ -89,25 +70,46 @@ function notifyForm(user) { h('p', { class: 'muted small' }, 'Anyone who knows the topic can read your pages and publish to it, so ', 'pick something unguessable rather than your name.'), - err, ok, - h('div', { class: 'row-actions' }, submit, test), + err, + submit, ); + const status = h('p', { class: 'muted', text: notifyStatus(user.ntfy_topic) }); + const summary = h('summary', { text: user.ntfy_topic ? 'Change topic' : 'Set a topic' }); + const details = h('details', { class: 'account-fold' }, summary, form); + + // Only offered once a topic is saved: the test publishes to whatever the + // server has stored, not to whatever is half-typed in the field. + const test = h('button', { + class: 'btn', type: 'button', text: 'Send a test push', + hidden: !user.ntfy_topic, + onclick: async () => { + test.disabled = true; + try { + await api.testNotification(); + toast('Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.'); + } catch (ex) { + toast(ex.message, 'error'); + } finally { + test.disabled = false; + } + }, + }); + form.addEventListener('submit', async (e) => { e.preventDefault(); err.hidden = true; - ok.hidden = true; submit.disabled = true; try { const updated = await api.setNotifyTarget(user.id, topic.value.trim()); // Keep the cached user in step, so the onboarding checklist stops // asking for this and the test button appears without a reload. state.me.user = updated; - ok.textContent = updated.ntfy_topic - ? 'Topic saved.' - : 'Topic cleared. Your pages go to the team’s fallback topic.'; - ok.hidden = false; + status.textContent = notifyStatus(updated.ntfy_topic); + summary.textContent = updated.ntfy_topic ? 'Change topic' : 'Set a topic'; test.hidden = !updated.ntfy_topic; + details.open = false; + toast(updated.ntfy_topic ? 'Topic saved' : 'Topic cleared. Your pages go to the team’s fallback topic.'); } catch (ex) { err.textContent = ex.message; err.hidden = false; @@ -115,7 +117,11 @@ function notifyForm(user) { submit.disabled = false; } }); - return form; + + return h('div', { class: 'card pw-form' }, + status, + h('div', { class: 'row-actions' }, test), + details); } // With password login switched off a password opens nothing, so somebody who @@ -131,14 +137,13 @@ function passwordSection(user, hasPassword) { ]; } return [ - h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })), + h('div', { class: 'page-head' }, h('h2', { text: 'Password' })), passwordForm(user, hasPassword), ]; } function passwordForm(user, hasPassword) { const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); - const ok = h('p', { class: 'form-ok', role: 'status', hidden: true }); const current = hasPassword ? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true }) : null; @@ -148,18 +153,24 @@ function passwordForm(user, hasPassword) { // A hidden username field lets password managers file the new password // under the right account. - const form = h('form', { class: 'card pw-form', autocomplete: 'on' }, + const form = h('form', { class: 'stacked-form', autocomplete: 'on' }, h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }), current && h('label', {}, h('span', { text: 'Current password' }), current), h('label', {}, h('span', { text: 'New password' }), next), h('label', {}, h('span', { text: 'Repeat new password' }), again), - err, ok, submit, + err, submit, ); + const status = h('p', { + class: 'muted', + text: hasPassword ? 'Password set.' : 'No password set — sign-in needs one of the other methods.', + }); + const summary = h('summary', { text: hasPassword ? 'Change password' : 'Set a password' }); + const details = h('details', { class: 'account-fold' }, summary, form); + form.addEventListener('submit', async (e) => { e.preventDefault(); err.hidden = true; - ok.hidden = true; if (next.value !== again.value) { err.textContent = 'The new passwords do not match.'; err.hidden = false; @@ -171,13 +182,14 @@ function passwordForm(user, hasPassword) { state.me.has_password = true; form.reset(); if (!current) { - // From now on the form needs the current-password field. + // From now on the form needs the current-password field, and a fresh + // render already comes up with the fold closed. render(); toast('Password saved'); return; } - ok.textContent = 'Password saved. Other devices have been signed out.'; - ok.hidden = false; + details.open = false; + toast('Password saved. Other devices have been signed out.'); } catch (ex) { err.textContent = ex.message; err.hidden = false; @@ -185,7 +197,7 @@ function passwordForm(user, hasPassword) { submit.disabled = false; } }); - return form; + return h('div', { class: 'card pw-form' }, status, details); } function shortcuts() {