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
103 lines
3.5 KiB
JavaScript
103 lines
3.5 KiB
JavaScript
// State shared between views: who is signed in, the user list, and the open
|
|
// queue that drives the badges.
|
|
|
|
import * as api from './api.js';
|
|
import { noteAvatars, clearAvatars } from './avatar.js';
|
|
|
|
export const state = {
|
|
me: null, // { user, has_password }
|
|
// How the server can be signed in to. The defaults are an older server's
|
|
// answer: passwords, no single sign-on.
|
|
auth: { password_login: true, oidc: { enabled: false, name: '' } },
|
|
open: [], // the default queue: open, not snoozed
|
|
teams: [], // the teams the viewer belongs to, each with their role
|
|
// Which team the whole app is scoped to right now; null means "All teams".
|
|
// Set only through setSelectedTeam below, never assigned directly, so every
|
|
// view stays in sync and the choice is remembered across reloads.
|
|
selectedTeamID: loadSelectedTeam(),
|
|
};
|
|
|
|
const SELECTED_TEAM_KEY = 'terdut.selectedTeam';
|
|
|
|
function loadSelectedTeam() {
|
|
try {
|
|
const raw = localStorage.getItem(SELECTED_TEAM_KEY);
|
|
return raw ? Number(raw) : null;
|
|
} catch {
|
|
return null; // storage unavailable, or nothing saved yet
|
|
}
|
|
}
|
|
|
|
// Callbacks to run whenever the selected team changes, so every view that
|
|
// cares — the queue's filter, the Team settings page, the selector's own
|
|
// trigger — stays in sync without a general event bus, following the one
|
|
// precedent for this in the codebase: onboarding.js's onRerender.
|
|
const teamListeners = [];
|
|
export function onTeamChange(cb) {
|
|
teamListeners.push(cb);
|
|
}
|
|
|
|
// setSelectedTeam changes which team the app is scoped to (id, or null for
|
|
// "All teams"), persists it — a durable preference, unlike the per-tab
|
|
// sessionStorage filter this replaces — and tells every registered listener.
|
|
export function setSelectedTeam(id) {
|
|
state.selectedTeamID = id;
|
|
try {
|
|
if (id == null) localStorage.removeItem(SELECTED_TEAM_KEY);
|
|
else localStorage.setItem(SELECTED_TEAM_KEY, String(id));
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
for (const cb of teamListeners) cb();
|
|
}
|
|
|
|
// The team whose schedule and settings the views act on: the selected team,
|
|
// falling back to the first one the viewer belongs to — which is everybody's
|
|
// only team until somebody makes a second, or the stored selection naming a
|
|
// team the account has since left.
|
|
export function currentTeam() {
|
|
const teams = state.teams || [];
|
|
return teams.find((t) => t.id === state.selectedTeamID) || teams[0] || null;
|
|
}
|
|
|
|
export function myID() {
|
|
return state.me ? state.me.user.id : null;
|
|
}
|
|
|
|
// The user list changes rarely; it is fetched once and then at most every
|
|
// five minutes, for the assign sheet and for display names.
|
|
let usersCache = null;
|
|
let usersAt = 0;
|
|
export async function users() {
|
|
if (!usersCache || Date.now() - usersAt > 5 * 60 * 1000) {
|
|
usersCache = await api.users();
|
|
noteAvatars(usersCache);
|
|
usersAt = Date.now();
|
|
}
|
|
return usersCache;
|
|
}
|
|
|
|
export async function loadTeams() {
|
|
state.teams = await api.teams();
|
|
// A stored id that no longer names one of the account's teams — left it, or
|
|
// this is simply a different account signed in on the same browser — is as
|
|
// good as unset.
|
|
if (state.selectedTeamID != null && !state.teams.some((t) => t.id === state.selectedTeamID)) {
|
|
state.selectedTeamID = null;
|
|
}
|
|
return state.teams;
|
|
}
|
|
|
|
export function reset() {
|
|
state.me = null;
|
|
state.open = [];
|
|
state.teams = [];
|
|
usersCache = null;
|
|
clearAvatars();
|
|
}
|
|
|
|
// After a picture changes: the cached list carries the old avatar_version.
|
|
export function forgetUsers() {
|
|
usersCache = null;
|
|
}
|