From fc8b0c8d58c5174670e28cb2d3cff364f10a9f25 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Mon, 21 Sep 2026 15:51:16 +0200 Subject: [PATCH] Let people set their own ntfy topic under Account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-run checklist's first step is "Set where your pages go", and its button navigated to /more — which had no field for it. Every new user was sent to a page that could not do the thing it sent them there for, and the only ways to actually set a topic were curl or asking an administrator. That has been true since the checklist shipped in v0.15.0. Account now has a Notifications section above the password form: the topic, prefilled and saved through the endpoint that already existed, and a Send a test push button. The test is offered only once a topic is saved, because it publishes what the server has stored rather than what is half-typed in the field, and a button that silently tested the previous value would be worse than no button. Saving assigns the response to state.me.user, so the checklist stops asking and the test button appears without a reload. Clearing works by saving an empty topic: the server treats that as "no topic of their own" rather than an error, and returns a user with ntfy_topic absent — it is omitempty — so the form reads the cleared state from the response rather than assuming it. The copy says the topic is a shared secret, because people reach for their own name and it is the only thing between a stranger and their pages. Same reason the topic stays out of an incident's timeline, which every API key can read. No server change: PUT /api/users/{id}/notify has been self-or-admin since #3 and needed nothing. Only the ntfy topic is per-person — the server is the install's one TERDUT_NTFY_URL and is not something a user picks. Also drops a line on that page still sending people to terdut-tui for user management, which stopped being true one release ago. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7 --- README.md | 22 +++++--- internal/web/static/js/account.js | 86 ++++++++++++++++++++++++++++++- internal/web/static/js/api.js | 5 ++ 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6d48095..7354ab8 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users The server serves a web UI at `/`: the incident queue, each incident's alerts and timeline with every action (acknowledge, assign, snooze, note, resolve, -archive), who is on call, the alert feed, and changing your own password. It is -built for a phone first. On a phone it has a bottom tab bar and a sticky action +archive), who is on call, the alert feed, and an *Account* tab for your own +password and the ntfy topic your pages go to. It is built for a phone first. On a phone it has a bottom tab bar and a sticky action bar, it follows the system's dark mode, and it can be added to the home screen. From 900px wide it switches to a sidebar with the queue and the incident side by side. Statistics remain in @@ -351,10 +351,20 @@ exactly as it was rather than with a hole in it. ### Push notifications With `TERDUT_NTFY_URL` set, an incident that opens is pushed to the on-call -person's phone through [ntfy](https://ntfy.sh). Set each user's topic with -`PUT /api/users/{id}/notify`; a user with no topic falls back to -`TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with nobody on call. -If neither yields a topic, nothing is queued. +person's phone through [ntfy](https://ntfy.sh). Everybody sets their own topic +under *Account* in the web UI, where a **Send a test push** button proves it +before an incident has to; `PUT /api/users/{id}/notify` is the same thing over +the API, and an administrator may set somebody else's. A user with no topic +falls back to `TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with +nobody on call. If neither yields a topic, nothing is queued. + +The **server** is the install's one ntfy, from `TERDUT_NTFY_URL`, and is not +something a user picks. Only the topic is per-person. + +A topic is a shared secret with the ntfy server: anyone who knows it can both +read the pages and publish to it, so an unguessable one is worth the trouble. +That is also why the topic never appears in an incident's timeline, which every +API key can read. Three things get pushed: diff --git a/internal/web/static/js/account.js b/internal/web/static/js/account.js index 7b42806..05e8efa 100644 --- a/internal/web/static/js/account.js +++ b/internal/web/static/js/account.js @@ -23,6 +23,9 @@ function render() { h('div', { class: 'account-name', text: user.username }), h('div', { class: 'account-email', text: user.email }))), + h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })), + notifyForm(user), + h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })), passwordForm(user, hasPassword), @@ -32,10 +35,91 @@ function render() { h('div', { class: 'page-head' }), h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'), - h('p', { class: 'foot-note', text: 'Schedule editing, statistics and user management are in terdut-tui for now.' }), + h('p', { class: 'foot-note', text: 'Statistics are in terdut-tui for now.' }), ); } +// Where this user's pages go. The onboarding checklist's first step sends +// people here for it, and until now there was nothing here to send them to: +// the topic could only be set with curl or by an administrator. +// +// 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 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, + value: user.ntfy_topic || '', + placeholder: 'terdut-a7f3c91e', + }); + 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' }, + h('label', {}, + h('span', { text: 'ntfy topic' }), + topic), + h('p', { class: 'muted small' }, + 'Subscribe to this topic in the ntfy app and incidents assigned to you ', + 'reach your phone. Leave it empty and they page the team’s fallback ', + 'topic instead.'), + // Worth saying plainly: people reach for their own name, and the topic is + // the only thing standing between a stranger and their pages. + 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), + ); + + 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; + test.hidden = !updated.ntfy_topic; + } catch (ex) { + err.textContent = ex.message; + err.hidden = false; + } finally { + submit.disabled = false; + } + }); + return form; +} + 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 }); diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js index b45fd6f..becc435 100644 --- a/internal/web/static/js/api.js +++ b/internal/web/static/js/api.js @@ -71,6 +71,11 @@ export const users = () => call('GET', '/users'); // about anybody else, which is what the admin page's per-user view needs. export const userTeams = (id) => call('GET', `/users/${id}/teams`); +// Where this user's pages go. An empty topic clears it, which the server +// treats as "no topic of their own" rather than an error. +export const setNotifyTarget = (id, ntfyTopic) => + call('PUT', `/users/${id}/notify`, { body: { ntfy_topic: ntfyTopic } }); + // incidents export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts }); export const incident = (id) => call('GET', `/incidents/${id}`);