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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user