423ed9b3a3
Statistics used to live only in terdut-tui; the account page said so. The page shows the same figures as the TUI's Stats tab -- incident counts, MTTA and MTTR, top alerts, and alert frequency by hour (UTC) and by day of week -- and adds a range picker (Today, 7d, 30d, 90d, All) that the TUI does not have. The ranges are day-granular because the server reads from/to as whole UTC dates, so there is no 24h chip. No server change: the page uses the existing /api/stats/* endpoints, which already scope to the caller's teams. Charts are inline SVG and plain elements sized from script, because the CSP forbids inline styles, inline scripts and CDN libraries. Removes the "statistics are in terdut-tui" notes from the account page and the README.
194 lines
7.1 KiB
JavaScript
194 lines
7.1 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 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),
|
||
|
||
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
||
passwordForm(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;
|
||
}
|
||
|
||
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 })))));
|
||
}
|