// Account: who you are signed in as, changing your password, signing out. import * as api from './api.js'; import { h, clear, icon, toast, openSheet, closeSheet } from './ui.js'; import { initial } from './format.js'; import { state, forgetUsers } from './state.js'; import { withAvatar, setAvatarVersion } from './avatar.js'; import { signOut, renderAccountNav } 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' }, withAvatar(h('div', { class: 'avatar', text: initial(user.username) }), user.id), h('div', {}, h('div', { class: 'account-name', text: user.username }), h('div', { class: 'account-email', text: user.email }), avatarControls(user))), 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 }))))); } // ---------- profile picture ---------- const CROP = 256; // the stored picture is CROP x CROP; the stage shows it 1:1 function avatarControls(user) { const input = h('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp,image/gif', hidden: true }); input.addEventListener('change', async () => { const file = input.files[0]; input.value = ''; if (!file) return; let bitmap; try { bitmap = await createImageBitmap(file); } catch { toast('That file is not an image this browser can read.', 'error'); return; } const blob = await cropSheet(bitmap); if (!blob) return; try { const { avatar_version: v } = await api.setAvatar(user.id, blob); changed(user, v); toast('Picture updated'); } catch (err) { toast(err.message, 'error'); } }); const remove = async () => { try { await api.removeAvatar(user.id); changed(user, null); toast('Picture removed'); } catch (err) { toast(err.message, 'error'); } }; return h('div', { class: 'avatar-actions' }, input, h('button', { class: 'btn btn-sm', type: 'button', onclick: () => input.click(), text: user.avatar_version != null ? 'Change picture' : 'Upload picture' }), user.avatar_version != null && h('button', { class: 'btn btn-sm', type: 'button', onclick: remove, text: 'Remove' })); } // The picture changed: keep the cached account and user list in step and repaint. function changed(user, version) { user.avatar_version = version ?? undefined; setAvatarVersion(user.id, version); forgetUsers(); renderAccountNav(); show(); } // A square stage with the image under it: drag to move, slider or wheel to // zoom. Resolves with a CROP x CROP PNG of exactly what the circle shows, or // null if dismissed. function cropSheet(bitmap) { const base = CROP / Math.min(bitmap.width, bitmap.height); // scale at which the image just covers the stage let zoom = 1; let cx = bitmap.width / 2; // the image point shown at the stage's centre let cy = bitmap.height / 2; const canvas = h('canvas', { class: 'crop-canvas', width: CROP, height: CROP }); const clamp = () => { const half = CROP / 2 / (base * zoom); cx = Math.min(bitmap.width - half, Math.max(half, cx)); cy = Math.min(bitmap.height - half, Math.max(half, cy)); }; const paint = (ctx = canvas.getContext('2d'), size = CROP) => { clamp(); const s = base * zoom * (size / CROP); ctx.clearRect(0, 0, size, size); ctx.imageSmoothingQuality = 'high'; ctx.drawImage(bitmap, size / 2 - cx * s, size / 2 - cy * s, bitmap.width * s, bitmap.height * s); }; const slider = h('input', { type: 'range', min: '1', max: '4', step: '0.01', value: '1', 'aria-label': 'Zoom' }); slider.addEventListener('input', () => { zoom = Number(slider.value); paint(); }); let drag = null; canvas.addEventListener('pointerdown', (e) => { canvas.setPointerCapture(e.pointerId); drag = { x: e.clientX, y: e.clientY }; }); canvas.addEventListener('pointermove', (e) => { if (!drag) return; const k = CROP / canvas.getBoundingClientRect().width / (base * zoom); cx -= (e.clientX - drag.x) * k; cy -= (e.clientY - drag.y) * k; drag = { x: e.clientX, y: e.clientY }; paint(); }); const end = () => { drag = null; }; canvas.addEventListener('pointerup', end); canvas.addEventListener('pointercancel', end); canvas.addEventListener('wheel', (e) => { e.preventDefault(); zoom = Math.min(4, Math.max(1, zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08))); slider.value = String(zoom); paint(); }, { passive: false }); return openSheet(() => { queueMicrotask(() => paint()); return [ h('h2', { class: 'sheet-title', text: 'Crop your picture' }), h('div', { class: 'crop-stage' }, canvas, h('div', { class: 'crop-mask', 'aria-hidden': 'true' })), h('label', { class: 'crop-zoom' }, h('span', { class: 'muted small', text: 'Zoom' }), slider), h('div', { class: 'sheet-actions' }, h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }), h('button', { class: 'btn btn-primary', type: 'button', text: 'Save', onclick: () => { const out = document.createElement('canvas'); out.width = CROP; out.height = CROP; paint(out.getContext('2d')); out.toBlob((b) => closeSheet(b), 'image/png'); }, })), ]; }); }