Files
terdut-server/internal/web/static/js/account.js
T
Niklas Ye fa6d82d6e5 Fix desktop layout bugs and show more incident actions directly
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.
2026-10-04 14:26:16 +02:00

225 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 })))));
}