Add notification channels and a provider abstraction

Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-12 21:00:40 +02:00
parent 6292ad6708
commit b114251f45
42 changed files with 2200 additions and 611 deletions
+124 -67
View File
@@ -10,11 +10,17 @@ const view = () => document.getElementById('view-more');
// Rendered once per visit rather than on every poll, so a half-typed password
// is never wiped out from under you.
export function show() {
render();
export async function show() {
let providers = [];
try {
providers = await api.notifyProviders();
} catch {
/* the card says the server can send nothing, which is the safe reading */
}
render(providers);
}
function render() {
function render(providers) {
const { user, has_password: hasPassword } = state.me;
clear(view(),
h('div', { class: 'card account-card' },
@@ -24,7 +30,7 @@ function render() {
h('div', { class: 'account-email', text: user.email }))),
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyForm(user),
notifyCard(user, providers),
...passwordSection(user, hasPassword),
@@ -59,79 +65,132 @@ function themePicker() {
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
}
// 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 notifyStatus(topic) {
return topic ? `Topic: ${topic}` : 'No topic set — pages go to the team’s fallback topic.';
// Where this user's pages go. Each channel is one way of reaching them — an ntfy
// topic today, other kinds as the server grows them — and a page goes to every
// channel that is switched on. Which kinds exist is the server's to say: the
// list comes from /notify/providers, so a deployment with one kind configured
// shows no picker.
function notifyCard(user, providers) {
const root = h('div', { class: 'card pw-form' });
let channels = user.channels || [];
const label = (type) => providers.find((p) => p.type === type)?.label || type;
// Keep the cached user in step, so the onboarding checklist stops asking for
// this and the test button works without a reload.
const commit = (next) => {
channels = next;
state.me.user.channels = next;
draw();
};
const row = (c) => {
const known = providers.some((p) => p.type === c.type);
return h('li', { class: c.enabled ? 'channel' : 'channel off' },
h('div', { class: 'channel-main' },
h('strong', { text: label(c.type) }),
h('code', { text: c.address }),
!c.enabled && h('span', { class: 'muted small', text: 'off' }),
!known && h('span', { class: 'load-error small', text: 'this server cannot deliver this kind' })),
h('div', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button', text: 'Send a test',
disabled: !known,
onclick: async (e) => {
e.target.disabled = true;
try {
await api.testChannel(user.id, c.id);
toast(`Sent. If nothing arrives, check the ${label(c.type)} address.`);
} catch (ex) {
toast(ex.message, 'error');
} finally {
e.target.disabled = !known;
}
},
}),
h('button', {
class: 'btn-sm', type: 'button', text: c.enabled ? 'Turn off' : 'Turn on',
onclick: async () => {
try {
const updated = await api.updateChannel(user.id, c.id, { enabled: !c.enabled });
commit(channels.map((x) => (x.id === c.id ? updated : x)));
} catch (ex) {
toast(ex.message, 'error');
}
},
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: async () => {
try {
await api.removeChannel(user.id, c.id);
commit(channels.filter((x) => x.id !== c.id));
} catch (ex) {
toast(ex.message, 'error');
}
},
})));
};
const draw = () => {
const enabled = channels.filter((c) => c.enabled).length;
clear(root,
h('p', { class: 'muted', text: channelStatus(channels.length, enabled, providers.length) }),
channels.length > 0 && h('ul', { class: 'channels' }, ...channels.map(row)),
providers.length > 0 && addChannelForm(user, providers, (created) => commit([...channels, created])));
};
draw();
return root;
}
function notifyForm(user) {
function channelStatus(total, enabled, providerCount) {
if (providerCount === 0) {
return 'This server has no way of sending notifications set up, so it cannot page anyone. ' +
'An administrator configures that in the deployment.';
}
if (total === 0) return 'No channels yet — pages go to the team’s fallback instead of you.';
if (enabled === 0) return 'Every channel is off — pages go to the team’s fallback instead of you.';
return `Pages reach you on ${enabled} channel${enabled === 1 ? '' : 's'}.`;
}
function addChannelForm(user, providers, onAdded) {
const err = h('p', { class: 'form-error', role: 'alert', 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 type = h('select', { name: 'type', 'aria-label': 'Kind of channel' },
...providers.map((p) => h('option', { value: p.type, text: p.label })));
const address = h('input', {
name: 'address', type: 'text', autocomplete: 'off',
autocapitalize: 'none', spellcheck: false, required: true,
});
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
const labelText = h('span');
const hint = h('p', { class: 'muted small' });
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add channel' });
// The field's name and the advice under it belong to the provider: an ntfy
// topic is a secret, an email address is not, and the page should not guess.
const describe = () => {
const p = providers.find((x) => x.type === type.value);
labelText.textContent = p?.address_label || 'Address';
hint.textContent = p?.address_hint || '';
};
type.addEventListener('change', describe);
describe();
const form = h('form', { class: 'stacked-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.'),
providers.length > 1 && h('label', {}, h('span', { text: 'Kind' }), type),
h('label', {}, labelText, address),
hint,
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;
}
},
});
submit);
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.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;
status.textContent = notifyStatus(updated.ntfy_topic);
summary.textContent = updated.ntfy_topic ? 'Change topic' : 'Set a topic';
test.hidden = !updated.ntfy_topic;
const created = await api.addChannel(user.id, type.value, address.value.trim());
address.value = '';
details.open = false;
toast(updated.ntfy_topic ? 'Topic saved' : 'Topic cleared. Your pages go to the team’s fallback topic.');
onAdded(created);
toast('Channel added. Send a test to make sure it arrives.');
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
@@ -140,10 +199,8 @@ function notifyForm(user) {
}
});
return h('div', { class: 'card pw-form' },
status,
h('div', { class: 'row-actions' }, test),
details);
const details = h('details', { class: 'account-fold' }, h('summary', { text: 'Add a channel' }), form);
return details;
}
// With password login switched off a password opens nothing, so somebody who