536dbcaebe
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
225 lines
11 KiB
JavaScript
225 lines
11 KiB
JavaScript
// The terdut-server client. The page is served by the server itself, so every
|
|
// call is same-origin and carries the session cookie.
|
|
|
|
export class ApiError extends Error {
|
|
constructor(status, message) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
// Called whenever the server says the session is gone, so the app can put the
|
|
// login form back up wherever the user happened to be.
|
|
let onUnauthorized = () => {};
|
|
export function setUnauthorizedHandler(fn) {
|
|
onUnauthorized = fn;
|
|
}
|
|
|
|
async function call(method, path, { query, body, signal } = {}) {
|
|
const url = new URL('/api' + path, location.origin);
|
|
for (const [k, v] of Object.entries(query || {})) {
|
|
if (v === '' || v == null) continue;
|
|
url.searchParams.set(k, v);
|
|
}
|
|
|
|
const headers = { Accept: 'application/json' };
|
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
|
|
let resp;
|
|
try {
|
|
resp = await fetch(url, {
|
|
method,
|
|
headers,
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
credentials: 'same-origin',
|
|
signal,
|
|
});
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') throw err;
|
|
throw new ApiError(0, 'Cannot reach the server.');
|
|
}
|
|
|
|
if (resp.status === 204) return null;
|
|
|
|
let data = null;
|
|
try {
|
|
data = await resp.json();
|
|
} catch {
|
|
/* non-JSON body: keep null */
|
|
}
|
|
|
|
if (!resp.ok) {
|
|
if (resp.status === 401 && path !== '/login') onUnauthorized();
|
|
const message = (data && data.error) || `Server answered ${resp.status}.`;
|
|
throw new ApiError(resp.status, message);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// session
|
|
export const me = () => call('GET', '/me');
|
|
// How this server can be signed in to: { password_login, oidc: { enabled, name } }.
|
|
export const authConfig = () => call('GET', '/auth/config');
|
|
export const login = (username, password) => call('POST', '/login', { body: { username, password } });
|
|
export const logout = () => call('POST', '/logout');
|
|
// Approve or refuse a sign-in a terminal started; code is what it is showing.
|
|
export const approveDevice = (code) => call('POST', '/oidc/device/approve', { body: { user_code: code } });
|
|
export const denyDevice = (code) => call('POST', '/oidc/device/deny', { body: { user_code: code } });
|
|
export const setPassword = (userID, password, currentPassword) =>
|
|
call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } });
|
|
|
|
// users
|
|
export const users = () => call('GET', '/users');
|
|
|
|
// A profile picture is raw image bytes, not JSON, so it does not go through call.
|
|
export async function setAvatar(userID, blob) {
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`/api/users/${userID}/avatar`, {
|
|
method: 'PUT', headers: { 'Content-Type': blob.type || 'image/png' }, body: blob, credentials: 'same-origin',
|
|
});
|
|
} catch {
|
|
throw new ApiError(0, 'Cannot reach the server.');
|
|
}
|
|
let data = null;
|
|
try {
|
|
data = await resp.json();
|
|
} catch {
|
|
/* keep null */
|
|
}
|
|
if (!resp.ok) {
|
|
if (resp.status === 401) onUnauthorized();
|
|
throw new ApiError(resp.status, (data && data.error) || `Server answered ${resp.status}.`);
|
|
}
|
|
return data;
|
|
}
|
|
export const removeAvatar = (userID) => call('DELETE', `/users/${userID}/avatar`);
|
|
|
|
// What one person is in. /teams answers "what am I in" and cannot be asked
|
|
// about anybody else, which is what the admin page's per-user view needs.
|
|
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
|
|
|
|
// Where this user's pages go: a list of channels, each a provider type and an
|
|
// address that provider understands. Only the owner is sent the addresses.
|
|
export const channels = (userID) => call('GET', `/users/${userID}/channels`);
|
|
export const addChannel = (userID, type, address) =>
|
|
call('POST', `/users/${userID}/channels`, { body: { type, address } });
|
|
// patch is { enabled } and/or { address }.
|
|
export const updateChannel = (userID, channelID, patch) =>
|
|
call('PATCH', `/users/${userID}/channels/${channelID}`, { body: patch });
|
|
export const removeChannel = (userID, channelID) =>
|
|
call('DELETE', `/users/${userID}/channels/${channelID}`);
|
|
export const testChannel = (userID, channelID) =>
|
|
call('POST', `/users/${userID}/channels/${channelID}/test`);
|
|
|
|
// Which kinds of channel this server can deliver: [{ type, label, address_label,
|
|
// address_hint }]. Empty when the server has nothing configured.
|
|
export const notifyProviders = () => call('GET', '/notify/providers');
|
|
|
|
// incidents
|
|
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
|
|
export const clusters = (query) => call('GET', '/incidents/clusters', { query });
|
|
export const incident = (id) => call('GET', `/incidents/${id}`);
|
|
export const timeline = (id) => call('GET', `/incidents/${id}/timeline`);
|
|
|
|
export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`);
|
|
export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`);
|
|
export const resolve = (id, resolution) => call('POST', `/incidents/${id}/resolve`, resolution ? { body: { resolution } } : {});
|
|
export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } });
|
|
export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec });
|
|
export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`);
|
|
export const archive = (id) => call('POST', `/incidents/${id}/archive`);
|
|
export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
|
|
export const addNote = (id, content, pinned = false) => call('POST', `/incidents/${id}/notes`, { body: { content, pinned } });
|
|
export const metrics = (id) => call('GET', `/incidents/${id}/metrics`);
|
|
export const similar = (id) => call('GET', `/incidents/${id}/similar`);
|
|
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
|
|
|
// stats
|
|
export const statsIncidents = (query) => call('GET', '/stats/incidents', { query });
|
|
export const statsTop = (query) => call('GET', '/stats/alerts/top', { query });
|
|
export const statsByHour = (query) => call('GET', '/stats/alerts/by-hour', { query });
|
|
export const statsHeatmap = (query) => call('GET', '/stats/alerts/heatmap', { query });
|
|
export const statsAlert = (name, query) => call('GET', `/stats/alerts/${encodeURIComponent(name)}`, { query });
|
|
export const statsByDay = (query) => call('GET', '/stats/alerts/by-day', { query });
|
|
|
|
// alerts
|
|
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
|
|
|
// schedule
|
|
// Sign-up, both halves unauthenticated: the caller has no account yet.
|
|
export const signupInfo = (invite) =>
|
|
call('GET', '/signup', { query: invite ? { invite } : {} });
|
|
export const signup = (body) => call('POST', '/signup', { body });
|
|
|
|
export const invites = (id) => call('GET', `/teams/${id}/invites`);
|
|
export const createInvite = (id, role, maxUses) =>
|
|
call('POST', `/teams/${id}/invites`, { body: { role, max_uses: maxUses } });
|
|
export const revokeInvite = (id, inviteID) => call('DELETE', `/teams/${id}/invites/${inviteID}`);
|
|
|
|
export const dismissOnboarding = (dismissed) =>
|
|
call('PUT', '/me/onboarding', { body: { dismissed } });
|
|
|
|
export const teams = () => call('GET', '/teams');
|
|
export const createTeam = (name) => call('POST', '/teams', { body: { name } });
|
|
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
|
|
export const deleteTeam = (id) => call('DELETE', `/teams/${id}`);
|
|
|
|
// A team's own settings. Every write is owner-only and every read is
|
|
// member-only; the server answers 403 and 404 respectively, so the UI shows
|
|
// what the role allows rather than guarding it.
|
|
export const teamMembers = (id) => call('GET', `/teams/${id}/members`);
|
|
export const addTeamMember = (id, userID, role) =>
|
|
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
|
|
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
|
|
|
|
// Which OIDC groups grant member and owner access to this team.
|
|
export const oidcGroups = (id) => call('GET', `/teams/${id}/oidc-groups`);
|
|
export const setOidcGroups = (id, body) => call('PUT', `/teams/${id}/oidc-groups`, { body });
|
|
|
|
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
|
|
export const createIntegration = (id, name) =>
|
|
call('POST', `/teams/${id}/integrations`, { body: { name } });
|
|
export const renameIntegration = (id, integrationID, name) =>
|
|
call('PATCH', `/teams/${id}/integrations/${integrationID}`, { body: { name } });
|
|
export const deleteIntegration = (id, integrationID) =>
|
|
call('DELETE', `/teams/${id}/integrations/${integrationID}`);
|
|
|
|
export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
|
|
export const createDeadmanSwitch = (id, body) =>
|
|
call('POST', `/teams/${id}/deadman/switches`, { body });
|
|
export const updateDeadmanSwitch = (id, switchID, body) =>
|
|
call('PUT', `/teams/${id}/deadman/switches/${switchID}`, { body });
|
|
export const deleteDeadmanSwitch = (id, switchID) =>
|
|
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
|
|
|
|
export const escalation = (id) => call('GET', `/teams/${id}/escalation`);
|
|
export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body });
|
|
|
|
export const assignSchedule = (id, userID, dates, replace = false) =>
|
|
call('POST', `/teams/${id}/schedule`, { body: { user_id: userID, dates, replace } });
|
|
export const unassignSchedule = (id, entryID) => call('DELETE', `/teams/${id}/schedule/${entryID}`);
|
|
|
|
// Administration. Every one of these is refused with 403 for anybody without
|
|
// the flag, so the UI hides the section rather than guarding it.
|
|
export const adminTeams = () => call('GET', '/admin/teams');
|
|
// One team and who is in it: { team, members }. /teams/{id}/members is
|
|
// member-only and answers 404 to an administrator from outside the team, which
|
|
// is the rule rather than an oversight -- this asks the other question.
|
|
export const adminTeam = (id) => call('GET', `/admin/teams/${id}`);
|
|
export const adminSettings = () => call('GET', '/admin/settings');
|
|
export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body });
|
|
export const setUserAdmin = (id, isAdmin) =>
|
|
call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
|
|
export const setUserDisabled = (id, disabled) =>
|
|
call('PUT', `/users/${id}/disabled`, { body: { disabled } });
|
|
export const deleteUser = (id) => call('DELETE', `/users/${id}`);
|
|
export const schedule = (teamID, from, to) =>
|
|
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
|
|
|
// One entry per team the viewer belongs to, for the teams that have somebody
|
|
// scheduled today. An empty array means nobody anywhere, which is a real answer
|
|
// rather than an error — unlike the pre-teams endpoint, which 404ed.
|
|
export const onCallNow = () => call('GET', '/schedule/current');
|