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 <details>/<summary> 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.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user