Files
terdut-server/internal/web/static/js/account.js
T
Niklas Ye 536dbcaebe Let users upload a profile picture, cropped in the browser
Users now have an avatar. The account page takes an image, lets the user
drag and zoom it under a circular mask, and uploads exactly what the circle
shows as a 256x256 PNG. The picture replaces the initial-letter circle in
the sidebar, on the account page, on the on-call hero and week strip, on
the schedule's day chips and in the incident assign menu; someone without
one keeps the initial.

The picture lives in Postgres (user_avatars, bytea) rather than on a
volume: the chart runs two replicas on a read-only root filesystem, and
the k8up pg_dump backup then covers it without any chart or operator
change. The server does not trust the client's crop. It decodes the PNG or
JPEG, checks the declared size before decoding, centre-crops to a square,
scales to 256 and re-encodes, which drops EXIF and anything that is not
pixels. Standard library only; WebP is cropped by the browser and arrives
as PNG, a raw WebP sent to the API is refused.

GET/PUT/DELETE /api/users/{id}/avatar: anyone signed in may read, only the
owner or an administrator may change. /api/me and /api/users gain
avatar_version, absent when there is no picture, which is also the ?v= that
busts the cache after a change. Incident timeline entries and the queue are
text-only and are unchanged.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-25 08:42:05 +01:00

426 lines
16 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, openSheet, closeSheet } from './ui.js';
import { initial } from './format.js';
import { state, forgetUsers } from './state.js';
import { withAvatar, setAvatarVersion } from './avatar.js';
import { signOut, renderAccountNav } 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 async function show() {
let providers = [];
try {
providers = await api.notifyProviders();
} catch {
/* the card says the server can send nothing, which is the safe reading */
}
render(providers);
}
function render(providers) {
const { user, has_password: hasPassword } = state.me;
clear(view(),
h('div', { class: 'card account-card' },
withAvatar(h('div', { class: 'avatar', text: initial(user.username) }), user.id),
h('div', {},
h('div', { class: 'account-name', text: user.username }),
h('div', { class: 'account-email', text: user.email }),
avatarControls(user))),
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyCard(user, providers),
...passwordSection(user, hasPassword),
h('div', { class: 'page-head' }, h('h2', { text: 'Appearance' })),
themePicker(),
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')),
);
}
// System / Light / Dark. Per browser, not per account: it lives in
// localStorage (see js/theme.js), the same place the selected team does.
const THEMES = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']];
function themePicker() {
const theme = window.terdutTheme;
const buttons = THEMES.map(([value, text]) => h('button', {
class: 'chip', type: 'button', role: 'radio', text,
onclick: () => { theme.set(value); sync(); },
}));
const sync = () => buttons.forEach((b, i) => b.setAttribute('aria-checked', String(THEMES[i][0] === theme.get())));
sync();
return h('div', { class: 'card card-pad' },
h('div', { class: 'chips theme-picker', role: 'radiogroup', 'aria-label': 'Theme' }, ...buttons),
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
}
// Where this user's pages go. Each channel is one way of reaching them — an ntfy
// topic today, other kinds as the server grows them — and a page goes to every
// channel that is switched on. Which kinds exist is the server's to say: the
// list comes from /notify/providers, so a deployment with one kind configured
// shows no picker.
function notifyCard(user, providers) {
const root = h('div', { class: 'card pw-form' });
let channels = user.channels || [];
const label = (type) => providers.find((p) => p.type === type)?.label || type;
// Keep the cached user in step, so the onboarding checklist stops asking for
// this and the test button works without a reload.
const commit = (next) => {
channels = next;
state.me.user.channels = next;
draw();
};
const row = (c) => {
const known = providers.some((p) => p.type === c.type);
return h('li', { class: c.enabled ? 'channel' : 'channel off' },
h('div', { class: 'channel-main' },
h('strong', { text: label(c.type) }),
h('code', { text: c.address }),
!c.enabled && h('span', { class: 'muted small', text: 'off' }),
!known && h('span', { class: 'load-error small', text: 'this server cannot deliver this kind' })),
h('div', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button', text: 'Send a test',
disabled: !known,
onclick: async (e) => {
e.target.disabled = true;
try {
await api.testChannel(user.id, c.id);
toast(`Sent. If nothing arrives, check the ${label(c.type)} address.`);
} catch (ex) {
toast(ex.message, 'error');
} finally {
e.target.disabled = !known;
}
},
}),
h('button', {
class: 'btn-sm', type: 'button', text: c.enabled ? 'Turn off' : 'Turn on',
onclick: async () => {
try {
const updated = await api.updateChannel(user.id, c.id, { enabled: !c.enabled });
commit(channels.map((x) => (x.id === c.id ? updated : x)));
} catch (ex) {
toast(ex.message, 'error');
}
},
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: async () => {
try {
await api.removeChannel(user.id, c.id);
commit(channels.filter((x) => x.id !== c.id));
} catch (ex) {
toast(ex.message, 'error');
}
},
})));
};
const draw = () => {
const enabled = channels.filter((c) => c.enabled).length;
clear(root,
h('p', { class: 'muted', text: channelStatus(channels.length, enabled, providers.length) }),
channels.length > 0 && h('ul', { class: 'channels' }, ...channels.map(row)),
providers.length > 0 && addChannelForm(user, providers, (created) => commit([...channels, created])));
};
draw();
return root;
}
function channelStatus(total, enabled, providerCount) {
if (providerCount === 0) {
return 'This server has no way of sending notifications set up, so it cannot page anyone. ' +
'An administrator configures that in the deployment.';
}
if (total === 0) return 'No channels yet — pages go to the team’s fallback instead of you.';
if (enabled === 0) return 'Every channel is off — pages go to the team’s fallback instead of you.';
return `Pages reach you on ${enabled} channel${enabled === 1 ? '' : 's'}.`;
}
function addChannelForm(user, providers, onAdded) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const type = h('select', { name: 'type', 'aria-label': 'Kind of channel' },
...providers.map((p) => h('option', { value: p.type, text: p.label })));
const address = h('input', {
name: 'address', type: 'text', autocomplete: 'off',
autocapitalize: 'none', spellcheck: false, required: true,
});
const labelText = h('span');
const hint = h('p', { class: 'muted small' });
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add channel' });
// The field's name and the advice under it belong to the provider: an ntfy
// topic is a secret, an email address is not, and the page should not guess.
const describe = () => {
const p = providers.find((x) => x.type === type.value);
labelText.textContent = p?.address_label || 'Address';
hint.textContent = p?.address_hint || '';
};
type.addEventListener('change', describe);
describe();
const form = h('form', { class: 'stacked-form' },
providers.length > 1 && h('label', {}, h('span', { text: 'Kind' }), type),
h('label', {}, labelText, address),
hint,
err,
submit);
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
submit.disabled = true;
try {
const created = await api.addChannel(user.id, type.value, address.value.trim());
address.value = '';
details.open = false;
onAdded(created);
toast('Channel added. Send a test to make sure it arrives.');
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
submit.disabled = false;
}
});
const details = h('details', { class: 'account-fold' }, h('summary', { text: 'Add a channel' }), form);
return 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 })))));
}
// ---------- profile picture ----------
const CROP = 256; // the stored picture is CROP x CROP; the stage shows it 1:1
function avatarControls(user) {
const input = h('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp,image/gif', hidden: true });
input.addEventListener('change', async () => {
const file = input.files[0];
input.value = '';
if (!file) return;
let bitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
toast('That file is not an image this browser can read.', 'error');
return;
}
const blob = await cropSheet(bitmap);
if (!blob) return;
try {
const { avatar_version: v } = await api.setAvatar(user.id, blob);
changed(user, v);
toast('Picture updated');
} catch (err) {
toast(err.message, 'error');
}
});
const remove = async () => {
try {
await api.removeAvatar(user.id);
changed(user, null);
toast('Picture removed');
} catch (err) {
toast(err.message, 'error');
}
};
return h('div', { class: 'avatar-actions' },
input,
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => input.click(), text: user.avatar_version != null ? 'Change picture' : 'Upload picture' }),
user.avatar_version != null && h('button', { class: 'btn btn-sm', type: 'button', onclick: remove, text: 'Remove' }));
}
// The picture changed: keep the cached account and user list in step and repaint.
function changed(user, version) {
user.avatar_version = version ?? undefined;
setAvatarVersion(user.id, version);
forgetUsers();
renderAccountNav();
show();
}
// A square stage with the image under it: drag to move, slider or wheel to
// zoom. Resolves with a CROP x CROP PNG of exactly what the circle shows, or
// null if dismissed.
function cropSheet(bitmap) {
const base = CROP / Math.min(bitmap.width, bitmap.height); // scale at which the image just covers the stage
let zoom = 1;
let cx = bitmap.width / 2; // the image point shown at the stage's centre
let cy = bitmap.height / 2;
const canvas = h('canvas', { class: 'crop-canvas', width: CROP, height: CROP });
const clamp = () => {
const half = CROP / 2 / (base * zoom);
cx = Math.min(bitmap.width - half, Math.max(half, cx));
cy = Math.min(bitmap.height - half, Math.max(half, cy));
};
const paint = (ctx = canvas.getContext('2d'), size = CROP) => {
clamp();
const s = base * zoom * (size / CROP);
ctx.clearRect(0, 0, size, size);
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bitmap, size / 2 - cx * s, size / 2 - cy * s, bitmap.width * s, bitmap.height * s);
};
const slider = h('input', { type: 'range', min: '1', max: '4', step: '0.01', value: '1', 'aria-label': 'Zoom' });
slider.addEventListener('input', () => { zoom = Number(slider.value); paint(); });
let drag = null;
canvas.addEventListener('pointerdown', (e) => {
canvas.setPointerCapture(e.pointerId);
drag = { x: e.clientX, y: e.clientY };
});
canvas.addEventListener('pointermove', (e) => {
if (!drag) return;
const k = CROP / canvas.getBoundingClientRect().width / (base * zoom);
cx -= (e.clientX - drag.x) * k;
cy -= (e.clientY - drag.y) * k;
drag = { x: e.clientX, y: e.clientY };
paint();
});
const end = () => { drag = null; };
canvas.addEventListener('pointerup', end);
canvas.addEventListener('pointercancel', end);
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
zoom = Math.min(4, Math.max(1, zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)));
slider.value = String(zoom);
paint();
}, { passive: false });
return openSheet(() => {
queueMicrotask(() => paint());
return [
h('h2', { class: 'sheet-title', text: 'Crop your picture' }),
h('div', { class: 'crop-stage' }, canvas, h('div', { class: 'crop-mask', 'aria-hidden': 'true' })),
h('label', { class: 'crop-zoom' }, h('span', { class: 'muted small', text: 'Zoom' }), slider),
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
h('button', {
class: 'btn btn-primary', type: 'button', text: 'Save',
onclick: () => {
const out = document.createElement('canvas');
out.width = CROP;
out.height = CROP;
paint(out.getContext('2d'));
out.toBlob((b) => closeSheet(b), 'image/png');
},
})),
];
});
}