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() {