// 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 notifyForm(user) { const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); const ok = h('p', { class: 'form-ok', role: 'status', 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' }); // 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 () => { err.hidden = true; ok.hidden = true; test.disabled = true; try { await api.testNotification(); ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.'; ok.hidden = false; } catch (ex) { err.textContent = ex.message; err.hidden = false; } finally { test.disabled = false; } }, }); const form = h('form', { class: 'card pw-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, ok, h('div', { class: 'row-actions' }, submit, test), ); form.addEventListener('submit', async (e) => { e.preventDefault(); err.hidden = true; ok.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; ok.textContent = updated.ntfy_topic ? 'Topic saved.' : 'Topic cleared. Your pages go to the team’s fallback topic.'; ok.hidden = false; test.hidden = !updated.ntfy_topic; } catch (ex) { err.textContent = ex.message; err.hidden = false; } finally { submit.disabled = false; } }); return form; } // 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: hasPassword ? 'Change password' : 'Set a password' })), passwordForm(user, hasPassword), ]; } function passwordForm(user, hasPassword) { const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); const ok = h('p', { class: 'form-ok', role: 'status', 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: 'card pw-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, ok, submit, ); form.addEventListener('submit', async (e) => { e.preventDefault(); err.hidden = true; ok.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. render(); toast('Password saved'); return; } ok.textContent = 'Password saved. Other devices have been signed out.'; ok.hidden = false; } catch (ex) { err.textContent = ex.message; err.hidden = false; } finally { submit.disabled = false; } }); return form; } 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 }))))); }