Let people set their own ntfy topic under Account
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
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user