fa6d82d6e5
On-call, the incident queue and the account page all had latent CSS bugs that only show up once the browser is wide enough to hit the desktop breakpoint (900px+): - On-call: .days reset its own margin to 0, which canceled the page-wide auto-centering on just that element, leaving the day list pinned to the left edge while every other card on the page centered normally. - Queue: the list pane stayed capped at 340-420px even with nothing selected, leaving the rest of the screen empty. It now fills the width until an incident is picked, then goes back to list+detail. - Account, admin user and admin team: .btn and .back-link are inline-flex, and margin:auto only centers a block box, so the Sign-out button and the two admin back-links sat left of their sibling cards instead of matching their width. Wrapped each in a block div. The incident detail action bar also folded Assign, Add note, Resolve and Clear acknowledgement into a "More" sheet sized for a phone's width. Desktop has the room, so it now shows them as direct buttons and hides More instead; Copy incident stays out of the bar since the header already has its own button for it. Filed as niklas/terdut-server#31, #32, #33, #34, each with a screenshot.
225 lines
8.6 KiB
JavaScript
225 lines
8.6 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),
|
||
|
||
...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' }),
|
||
// 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')),
|
||
);
|
||
}
|
||
|
||
// 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 })))));
|
||
}
|