b114251f45
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
302 lines
12 KiB
JavaScript
302 lines
12 KiB
JavaScript
// 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 })))));
|
||
}
|