Files
terdut-server/internal/web/static/js/account.js
T
Niklas Ye b114251f45 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
2026-10-12 21:00:40 +02:00

302 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Account: who you are signed in as, changing your password, signing out.
import * as api from './api.js';
import { h, clear, icon, toast } from './ui.js';
import { initial } from './format.js';
import { state } from './state.js';
import { signOut } from './app.js';
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 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(providers) {
const { user, has_password: hasPassword } = state.me;
clear(view(),
h('div', { class: 'card account-card' },
h('div', { class: 'avatar', text: initial(user.username) }),
h('div', {},
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' })),
notifyCard(user, providers),
...passwordSection(user, hasPassword),
h('div', { class: 'page-head' }, h('h2', { text: 'Appearance' })),
themePicker(),
h('div', { class: 'only-desktop' },
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
h('div', { class: 'card' }, shortcuts())),
h('div', { class: 'page-head' }),
// Wrapped in a div: .btn is inline-flex, and only a block-level element
// picks up .view-page > *'s margin:auto centering (see app.css:316).
h('div', {}, h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out')),
);
}
// System / Light / Dark. Per browser, not per account: it lives in
// localStorage (see js/theme.js), the same place the selected team does.
const THEMES = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']];
function themePicker() {
const theme = window.terdutTheme;
const buttons = THEMES.map(([value, text]) => h('button', {
class: 'chip', type: 'button', role: 'radio', text,
onclick: () => { theme.set(value); sync(); },
}));
const sync = () => buttons.forEach((b, i) => b.setAttribute('aria-checked', String(THEMES[i][0] === theme.get())));
sync();
return h('div', { class: 'card card-pad' },
h('div', { class: 'chips theme-picker', role: 'radiogroup', 'aria-label': 'Theme' }, ...buttons),
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
}
// 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 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 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 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' },
providers.length > 1 && h('label', {}, h('span', { text: 'Kind' }), type),
h('label', {}, labelText, address),
hint,
err,
submit);
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
submit.disabled = true;
try {
const created = await api.addChannel(user.id, type.value, address.value.trim());
address.value = '';
details.open = false;
onAdded(created);
toast('Channel added. Send a test to make sure it arrives.');
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
submit.disabled = false;
}
});
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
// has none is not asked to make one. Somebody who does keeps the form: it is
// how they change or get rid of a credential the server still remembers.
function passwordSection(user, hasPassword) {
if (!hasPassword && state.auth.password_login === false) {
const name = state.auth.oidc?.name || 'single sign-on';
return [
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
h('div', { class: 'card' },
h('p', { class: 'muted', text: `You sign in with ${name}, and this server has turned password login off.` })),
];
}
return [
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
passwordForm(user, hasPassword),
];
}
function passwordForm(user, hasPassword) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const current = hasPassword
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
: null;
const next = h('input', { name: 'next', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
const again = h('input', { name: 'again', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save password' });
// A hidden username field lets password managers file the new password
// under the right account.
const form = h('form', { class: 'stacked-form', autocomplete: 'on' },
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
current && h('label', {}, h('span', { text: 'Current password' }), current),
h('label', {}, h('span', { text: 'New password' }), next),
h('label', {}, h('span', { text: 'Repeat new password' }), again),
err, submit,
);
const status = h('p', {
class: 'muted',
text: hasPassword ? 'Password set.' : 'No password set — sign-in needs one of the other methods.',
});
const summary = h('summary', { text: hasPassword ? 'Change password' : 'Set a password' });
const details = h('details', { class: 'account-fold' }, summary, form);
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
if (next.value !== again.value) {
err.textContent = 'The new passwords do not match.';
err.hidden = false;
return;
}
submit.disabled = true;
try {
await api.setPassword(user.id, next.value, current ? current.value : '');
state.me.has_password = true;
form.reset();
if (!current) {
// From now on the form needs the current-password field, and a fresh
// render already comes up with the fold closed.
render();
toast('Password saved');
return;
}
details.open = false;
toast('Password saved. Other devices have been signed out.');
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
submit.disabled = false;
}
});
return h('div', { class: 'card pw-form' }, status, details);
}
function shortcuts() {
const rows = [
['j / k', 'Move through the queue'],
['Enter', 'Open incident'],
['Esc', 'Back to the queue'],
['f', 'Cycle the queue filter'],
['a / A', 'Acknowledge / clear acknowledgement'],
['R', 'Resolve (asks first)'],
['s', 'Assign'],
['z / Z', 'Snooze / end snooze'],
['c', 'Add a note'],
['x', 'Archive / unarchive a resolved incident'],
['r', 'Refresh now'],
];
return h('table', { class: 'kbd-table' },
h('tbody', {}, rows.map(([k, v]) =>
h('tr', {},
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
h('td', { text: v })))));
}