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}`);