Compare commits

..

2 Commits

Author SHA1 Message Date
Niklas Ye 0ee576f793 Set the chart's placeholder version to 0.32.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 4m2s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 31s
Release / image (push) Successful in 1m10s
Release / scan-image (push) Successful in 3s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 949d659 and e5b4df7, so a
tree heading for v0.32.0 doesn't say 0.31.0.
2026-09-27 22:05:26 +02:00
Niklas Ye b610b1817a 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.
2026-09-27 22:04:59 +02:00
3 changed files with 57 additions and 43 deletions
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight: # appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is # image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing. # metadata and drives nothing.
version: 0.31.0 version: 0.32.0
appVersion: "v0.31.0" appVersion: "v0.32.0"
+2
View File
@@ -639,6 +639,8 @@ details[open] > summary { margin-bottom: 8px; }
.account-name { font-size: 18px; font-weight: 750; } .account-name { font-size: 18px; font-weight: 750; }
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; } .account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.pw-form { display: grid; gap: 12px; padding: 16px; } .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 { .form-ok {
margin: 0; padding: 10px 12px; margin: 0; padding: 10px 12px;
background: var(--ok-soft); color: var(--ok); background: var(--ok-soft); color: var(--ok);
+53 -41
View File
@@ -43,9 +43,12 @@ function render() {
// //
// The topic is the whole address — the server it is published to is the // 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. // 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) { function notifyForm(user) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); 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', { const topic = h('input', {
name: 'ntfy_topic', type: 'text', autocomplete: 'off', name: 'ntfy_topic', type: 'text', autocomplete: 'off',
autocapitalize: 'none', spellcheck: false, autocapitalize: 'none', spellcheck: false,
@@ -54,29 +57,7 @@ function notifyForm(user) {
}); });
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' }); 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 const form = h('form', { class: 'stacked-form' },
// 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' },
h('label', {}, h('label', {},
h('span', { text: 'ntfy topic' }), h('span', { text: 'ntfy topic' }),
topic), topic),
@@ -89,25 +70,46 @@ function notifyForm(user) {
h('p', { class: 'muted small' }, h('p', { class: 'muted small' },
'Anyone who knows the topic can read your pages and publish to it, so ', 'Anyone who knows the topic can read your pages and publish to it, so ',
'pick something unguessable rather than your name.'), 'pick something unguessable rather than your name.'),
err, ok, err,
h('div', { class: 'row-actions' }, submit, test), 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) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
err.hidden = true; err.hidden = true;
ok.hidden = true;
submit.disabled = true; submit.disabled = true;
try { try {
const updated = await api.setNotifyTarget(user.id, topic.value.trim()); const updated = await api.setNotifyTarget(user.id, topic.value.trim());
// Keep the cached user in step, so the onboarding checklist stops // Keep the cached user in step, so the onboarding checklist stops
// asking for this and the test button appears without a reload. // asking for this and the test button appears without a reload.
state.me.user = updated; state.me.user = updated;
ok.textContent = updated.ntfy_topic status.textContent = notifyStatus(updated.ntfy_topic);
? 'Topic saved.' summary.textContent = updated.ntfy_topic ? 'Change topic' : 'Set a topic';
: 'Topic cleared. Your pages go to the team’s fallback topic.';
ok.hidden = false;
test.hidden = !updated.ntfy_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) { } catch (ex) {
err.textContent = ex.message; err.textContent = ex.message;
err.hidden = false; err.hidden = false;
@@ -115,7 +117,11 @@ function notifyForm(user) {
submit.disabled = false; 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 // With password login switched off a password opens nothing, so somebody who
@@ -131,14 +137,13 @@ function passwordSection(user, hasPassword) {
]; ];
} }
return [ 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), passwordForm(user, hasPassword),
]; ];
} }
function passwordForm(user, hasPassword) { function passwordForm(user, hasPassword) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); 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 const current = hasPassword
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true }) ? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
: null; : null;
@@ -148,18 +153,24 @@ function passwordForm(user, hasPassword) {
// A hidden username field lets password managers file the new password // A hidden username field lets password managers file the new password
// under the right account. // 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 }), h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
current && h('label', {}, h('span', { text: 'Current password' }), current), current && h('label', {}, h('span', { text: 'Current password' }), current),
h('label', {}, h('span', { text: 'New password' }), next), h('label', {}, h('span', { text: 'New password' }), next),
h('label', {}, h('span', { text: 'Repeat new password' }), again), 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) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
err.hidden = true; err.hidden = true;
ok.hidden = true;
if (next.value !== again.value) { if (next.value !== again.value) {
err.textContent = 'The new passwords do not match.'; err.textContent = 'The new passwords do not match.';
err.hidden = false; err.hidden = false;
@@ -171,13 +182,14 @@ function passwordForm(user, hasPassword) {
state.me.has_password = true; state.me.has_password = true;
form.reset(); form.reset();
if (!current) { 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(); render();
toast('Password saved'); toast('Password saved');
return; return;
} }
ok.textContent = 'Password saved. Other devices have been signed out.'; details.open = false;
ok.hidden = false; toast('Password saved. Other devices have been signed out.');
} catch (ex) { } catch (ex) {
err.textContent = ex.message; err.textContent = ex.message;
err.hidden = false; err.hidden = false;
@@ -185,7 +197,7 @@ function passwordForm(user, hasPassword) {
submit.disabled = false; submit.disabled = false;
} }
}); });
return form; return h('div', { class: 'card pw-form' }, status, details);
} }
function shortcuts() { function shortcuts() {