// 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 function show() { render(); } function render() { 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' })), notifyForm(user), ...passwordSection(user, hasPassword), h('div', { class: 'only-desktop' }, h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })), h('div', { class: 'card' }, shortcuts())), h('div', { class: 'page-head' }), h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'), ); } // 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.'; } function notifyForm(user) { 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 submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' }); 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.'), 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; } }, }); 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; details.open = false; toast(updated.ntfy_topic ? 'Topic saved' : 'Topic cleared. Your pages go to the team’s fallback topic.'); } catch (ex) { err.textContent = ex.message; err.hidden = false; } finally { submit.disabled = false; } }); return h('div', { class: 'card pw-form' }, status, h('div', { class: 'row-actions' }, test), 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 }))))); }