// 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 }))))); }