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
This commit is contained in:
Niklas Ye
2026-10-25 08:42:05 +01:00
parent 50e9ebcbc2
commit 536dbcaebe
15 changed files with 603 additions and 17 deletions
+199
View File
@@ -0,0 +1,199 @@
package api
import (
"bytes"
"database/sql"
"errors"
"image"
"image/color"
"image/draw"
_ "image/jpeg" // registers the JPEG decoder with image.Decode
"image/png"
"io"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
const (
// avatarSize is the side of the stored square picture. The web UI crops to
// it before uploading; the server enforces it anyway.
avatarSize = 256
// maxAvatarBytes caps the upload. The web UI sends a ~256x256 PNG, a few
// tens of KiB; this leaves room for a client that sends a photo as is.
maxAvatarBytes = 1 << 20
// maxAvatarPixels bounds the decoded size, checked from the header before
// decoding: a small file can declare an enormous canvas.
maxAvatarPixels = 16 << 20
)
// normalizeAvatar decodes a PNG or JPEG, crops it to its centred square, scales
// it to avatarSize and encodes it as PNG. Decoding and re-encoding is what
// makes the stored bytes trustworthy: EXIF, trailing data and anything that is
// not pixels do not survive it.
func normalizeAvatar(raw []byte) ([]byte, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return nil, errors.New("the picture must be a PNG or JPEG image")
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxAvatarPixels {
return nil, errors.New("the picture is too large")
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, errors.New("the picture must be a PNG or JPEG image")
}
b := img.Bounds()
side := min(b.Dx(), b.Dy())
src := image.Rect(
b.Min.X+(b.Dx()-side)/2, b.Min.Y+(b.Dy()-side)/2,
b.Min.X+(b.Dx()-side)/2+side, b.Min.Y+(b.Dy()-side)/2+side)
var out *image.NRGBA
if side <= avatarSize {
out = image.NewNRGBA(image.Rect(0, 0, side, side))
draw.Draw(out, out.Bounds(), img, src.Min, draw.Src)
} else {
out = boxScale(img, src, avatarSize)
}
var buf bytes.Buffer
if err := png.Encode(&buf, out); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// boxScale shrinks the square region src of img to size x size by averaging the
// source pixels each output pixel covers. Plain area averaging is enough for a
// downscale and keeps the standard library the only dependency.
func boxScale(img image.Image, src image.Rectangle, size int) *image.NRGBA {
out := image.NewNRGBA(image.Rect(0, 0, size, size))
n := src.Dx()
for y := 0; y < size; y++ {
y0, y1 := src.Min.Y+y*n/size, src.Min.Y+(y+1)*n/size
for x := 0; x < size; x++ {
x0, x1 := src.Min.X+x*n/size, src.Min.X+(x+1)*n/size
var r, g, b, a, cnt uint64
for sy := y0; sy < y1; sy++ {
for sx := x0; sx < x1; sx++ {
pr, pg, pb, pa := img.At(sx, sy).RGBA()
r, g, b, a, cnt = r+uint64(pr), g+uint64(pg), b+uint64(pb), a+uint64(pa), cnt+1
}
}
if cnt == 0 {
continue
}
// RGBA() is alpha-premultiplied 16-bit; undo that for NRGBA.
a /= cnt
if a == 0 {
continue
}
r, g, b = r/cnt*0xffff/a, g/cnt*0xffff/a, b/cnt*0xffff/a
out.SetNRGBA(x, y, color.NRGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)})
}
}
return out
}
func handleGetAvatar(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
var data []byte
var version int64
err = db.QueryRowContext(r.Context(),
"SELECT data, updated_at FROM user_avatars WHERE user_id = $1", id).Scan(&data, &version)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("no avatar"))
return
}
if err != nil {
serverError(w, r, err)
return
}
etag := `"` + strconv.FormatInt(version, 10) + `"`
w.Header().Set("ETag", etag)
// private: it is behind a login. The URL carries ?v=, so the browser can
// keep it for a long time without ever showing a stale picture.
w.Header().Set("Cache-Control", "private, max-age=86400")
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write(data)
}
}
// handleSetAvatar stores the request body, a PNG or JPEG, as the user's picture.
func handleSetAvatar(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, id) {
return
}
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxAvatarBytes))
if err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
respond(w, http.StatusRequestEntityTooLarge, errResp("the picture is too large"))
return
}
respond(w, http.StatusBadRequest, errResp("could not read the picture"))
return
}
data, err := normalizeAvatar(raw)
if err != nil {
respond(w, http.StatusBadRequest, errResp(err.Error()))
return
}
version := time.Now().UnixMilli()
res, err := db.ExecContext(r.Context(),
`INSERT INTO user_avatars (user_id, data, updated_at)
SELECT id, $2, $3 FROM users WHERE id = $1
ON CONFLICT (user_id) DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at`,
id, data, version)
if err != nil {
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
respond(w, http.StatusOK, map[string]int64{"avatar_version": version})
}
}
func handleDeleteAvatar(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, id) {
return
}
if _, err := db.ExecContext(r.Context(),
"DELETE FROM user_avatars WHERE user_id = $1", id); err != nil {
serverError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+154
View File
@@ -0,0 +1,154 @@
package api_test
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"net/http"
"testing"
)
func testImage(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 200, A: 255})
}
}
return img
}
func pngBytes(t *testing.T, img image.Image) []byte {
t.Helper()
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
// putRaw sends a non-JSON body with the administrator's key.
func (s *ts) putRaw(t *testing.T, path string, body []byte) *http.Response {
t.Helper()
req, _ := http.NewRequest(http.MethodPut, s.URL+path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+s.key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func TestAvatar_UploadNormalizesAndServes(t *testing.T) {
s := newTS(t)
uid, call := member(t, s, "ava")
path := "/api/users/" + id64(uid) + "/avatar"
if resp := call(http.MethodGet, path, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET before upload: %d, want 404", resp.StatusCode)
}
// A non-square JPEG, bigger than the stored size: cropped and scaled to 256².
var jb bytes.Buffer
if err := jpeg.Encode(&jb, testImage(600, 400), nil); err != nil {
t.Fatal(err)
}
resp := s.putRaw(t, path, jb.Bytes()) // an administrator may set anybody's
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT: %d", resp.StatusCode)
}
var out struct {
Version int64 `json:"avatar_version"`
}
decode(t, resp, &out)
resp = call(http.MethodGet, path, nil)
if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "image/png" {
t.Fatalf("GET: %d %q", resp.StatusCode, resp.Header.Get("Content-Type"))
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
img, err := png.Decode(bytes.NewReader(body))
if err != nil {
t.Fatalf("served bytes are not a PNG: %v", err)
}
if b := img.Bounds(); b.Dx() != 256 || b.Dy() != 256 {
t.Errorf("stored size %v, want 256x256", b)
}
var users []struct {
ID int64 `json:"id"`
AvatarVersion *int64 `json:"avatar_version"`
}
decode(t, call(http.MethodGet, "/api/users", nil), &users)
found := false
for _, u := range users {
if u.ID == uid {
found = true
if u.AvatarVersion == nil || *u.AvatarVersion != out.Version {
t.Errorf("user list avatar_version = %v, want %d", u.AvatarVersion, out.Version)
}
}
}
if !found {
t.Fatal("user not listed")
}
if resp := call(http.MethodDelete, path, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("DELETE: %d", resp.StatusCode)
}
if resp := call(http.MethodGet, path, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET after delete: %d, want 404", resp.StatusCode)
}
}
func TestAvatar_Rejections(t *testing.T) {
s := newTS(t)
_, other := member(t, s, "other")
uid, _ := member(t, s, "ava2")
path := "/api/users/" + id64(uid) + "/avatar"
if resp := other(http.MethodPut, path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("another member PUT: %d, want 403", resp.StatusCode)
}
if resp := other(http.MethodDelete, path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("another member DELETE: %d, want 403", resp.StatusCode)
}
if resp := s.putRaw(t, path, []byte("not an image")); resp.StatusCode != http.StatusBadRequest {
t.Errorf("garbage: %d, want 400", resp.StatusCode)
}
if resp := s.putRaw(t, path, make([]byte, 2<<20)); resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Errorf("oversize: %d, want 413", resp.StatusCode)
}
if resp := s.putRaw(t, "/api/users/999999/avatar", pngBytes(t, testImage(32, 32))); resp.StatusCode != http.StatusNotFound {
t.Errorf("unknown user: %d, want 404", resp.StatusCode)
}
req, _ := http.NewRequest(http.MethodGet, s.URL+path, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("unauthenticated GET: %d, want 401", resp.StatusCode)
}
}
func TestAvatar_RemovedWithTheUser(t *testing.T) {
s := newTS(t)
uid, _ := member(t, s, "gone")
path := "/api/users/" + id64(uid) + "/avatar"
if resp := s.putRaw(t, path, pngBytes(t, testImage(64, 64))); resp.StatusCode != http.StatusOK {
t.Fatalf("PUT: %d", resp.StatusCode)
}
if resp := s.req(t, http.MethodDelete, "/api/users/"+id64(uid), nil); resp.StatusCode >= 300 {
t.Fatalf("delete user: %d", resp.StatusCode)
}
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM user_avatars WHERE user_id = $1", uid).Scan(&n); err != nil || n != 0 {
t.Errorf("avatar rows after user delete = %d (%v), want 0", n, err)
}
}
+5
View File
@@ -117,6 +117,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
// set up" that the person holding the phone can confirm.
r.Post("/api/users/{id}/channels/{channelID}/test", handleTestChannel(db, notify))
r.Put("/api/users/{id}/password", handleSetPassword(db))
// The picture itself is readable by anyone signed in (like the user
// list), changeable only by its owner or an administrator.
r.Get("/api/users/{id}/avatar", handleGetAvatar(db))
r.Put("/api/users/{id}/avatar", handleSetAvatar(db))
r.Delete("/api/users/{id}/avatar", handleDeleteAvatar(db))
r.Get("/api/users/{id}/api-keys", handleListAPIKeys(db))
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
+4 -4
View File
@@ -117,7 +117,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
caller, _ := userFromContext(r.Context())
seeAll := caller.IsAdmin
rows, err := db.QueryContext(r.Context(),
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users ORDER BY id")
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at, (SELECT updated_at FROM user_avatars WHERE user_id = users.id) FROM users ORDER BY id")
if err != nil {
serverError(w, r, err)
return
@@ -129,7 +129,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
var u models.User
var ts int64
var disabled *int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion); err != nil {
serverError(w, r, err)
return
}
@@ -397,8 +397,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var ts int64
var disabled *int64
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled)
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at, (SELECT updated_at FROM user_avatars WHERE user_id = users.id) FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion)
if err != nil {
return u, err
}
@@ -0,0 +1,11 @@
-- A user's profile picture: one already-cropped, re-encoded 256x256 PNG per
-- user. Kept in Postgres rather than on a volume so every replica serves it and
-- the pg_dump backup covers it. updated_at is in milliseconds, not seconds like
-- the other timestamps, because it doubles as the cache-busting version of the
-- image URL and a re-upload within the same second must change it.
CREATE TABLE user_avatars (
user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data bytea NOT NULL,
updated_at bigint NOT NULL
);
+5
View File
@@ -19,6 +19,11 @@ type User struct {
// every acknowledgement and timeline entry they made.
DisabledAt *time.Time `json:"disabled_at,omitempty"`
// AvatarVersion identifies the user's current profile picture, or is nil
// when they have none and clients show their initials. It changes on every
// upload, so it goes in the image URL (?v=) as a cache buster.
AvatarVersion *int64 `json:"avatar_version,omitempty"`
// IsAdmin is the system administrator flag: managing users and API keys.
// Not omitempty — a client has to be able to tell "false" from "this server
// is too old to have the field", and the web UI decides what to show from
+12
View File
@@ -789,6 +789,18 @@ details[open] > summary { margin-bottom: 8px; }
font-weight: 750; font-size: 17px; text-transform: uppercase;
}
.avatar.none { background: var(--surface-2); color: var(--faint); }
/* A picture lies over the initial, which stays underneath as the fallback. */
.avatar, .rota-chip, .nav-avatar { position: relative; overflow: hidden; }
.avatar-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.avatar-sm { width: 24px; height: 24px; font-size: 12px; }
.avatar-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.crop-stage { position: relative; width: 256px; max-width: 100%; aspect-ratio: 1; margin: 0 auto 12px; overflow: hidden; border-radius: var(--radius); background: var(--surface-2); touch-action: none; }
.crop-canvas { display: block; width: 100%; height: 100%; cursor: grab; }
.crop-canvas:active { cursor: grabbing; }
/* The circle the picture will be shown in: everything outside it is dimmed. */
.crop-mask { position: absolute; inset: 0; border-radius: 50%; box-shadow: 0 0 0 999px rgba(0, 0, 0, 0.5); pointer-events: none; }
.crop-zoom { display: grid; gap: 4px; width: 256px; max-width: 100%; margin: 0 auto 12px; }
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
is easier to spot there than this plain accent-coloured text. */
+129 -5
View File
@@ -1,10 +1,11 @@
// 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 { h, clear, icon, toast, openSheet, closeSheet } from './ui.js';
import { initial } from './format.js';
import { state } from './state.js';
import { signOut } from './app.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');
@@ -24,10 +25,11 @@ function render(providers) {
const { user, has_password: hasPassword } = state.me;
clear(view(),
h('div', { class: 'card account-card' },
h('div', { class: 'avatar', text: initial(user.username) }),
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 }))),
h('div', { class: 'account-email', text: user.email }),
avatarControls(user))),
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyCard(user, providers),
@@ -299,3 +301,125 @@ function shortcuts() {
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');
},
})),
];
});
}
+24
View File
@@ -72,6 +72,30 @@ export const setPassword = (userID, 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`);
+9 -3
View File
@@ -3,7 +3,7 @@
import * as api from './api.js';
import * as ui from './ui.js';
import * as poll from './poll.js';
import { state, reset, loadTeams } from './state.js';
import { state, reset, loadTeams, users as ui_users } from './state.js';
import * as queue from './queue.js';
import * as incident from './incident.js';
import * as oncall from './oncall.js';
@@ -17,6 +17,7 @@ import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js';
import * as device from './device.js';
import { initial } from './format.js';
import { withAvatar, setAvatarVersion } from './avatar.js';
const $ = (id) => document.getElementById(id);
@@ -473,10 +474,13 @@ export async function signOut() {
// The sidebar's Account link shows who is signed in: an avatar and the name,
// at the foot of the desktop sidebar. The link keeps its aria-label, so a
// screen reader still hears "Account".
function renderAccountNav() {
export function renderAccountNav() {
const name = state.me?.user?.username;
if (!name) return;
$('nav-avatar').textContent = initial(name);
$('nav-avatar').replaceChildren(initial(name));
$('nav-avatar').classList.remove('has-img');
setAvatarVersion(state.me.user.id, state.me.user.avatar_version);
withAvatar($('nav-avatar'), state.me.user.id);
$('nav-avatar').hidden = false;
$('nav-account-label').textContent = name;
}
@@ -488,6 +492,8 @@ function showApp() {
$('login').hidden = true;
$('app').hidden = false;
render();
// Who has a picture: the first paint shows initials, the list repaints it.
ui_users().then(render).catch(() => {});
poll.start(refresh);
poll.now();
}
+35
View File
@@ -0,0 +1,35 @@
// Profile pictures. A user either has one (the server says so with an
// avatar_version, which is also the cache buster in the image URL) or shows
// their initial in a coloured circle, as before.
const versions = new Map(); // user id -> avatar_version
// Learn from a user list (or a single user) which people have a picture.
export function noteAvatars(list) {
for (const u of list || []) setAvatarVersion(u.id, u.avatar_version);
}
export function setAvatarVersion(userID, version) {
if (version == null) versions.delete(userID);
else versions.set(userID, version);
}
export function clearAvatars() {
versions.clear();
}
// withAvatar lays the person's picture over el, which keeps its initial
// underneath: if the image fails to load, the initial is what shows.
export function withAvatar(el, userID) {
const v = versions.get(userID);
if (v == null) return el;
el.classList.add('has-img');
const img = document.createElement('img');
img.className = 'avatar-img';
img.alt = '';
img.decoding = 'async';
img.src = `/api/users/${userID}/avatar?v=${v}`;
img.addEventListener('error', () => img.remove());
el.append(img);
return el;
}
+3 -2
View File
@@ -7,9 +7,10 @@ import {
h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
} from './ui.js';
import {
ago, when, until, duration, isFuture, originOf, STATUS_LABEL,
ago, when, until, duration, isFuture, originOf, STATUS_LABEL, initial,
} from './format.js';
import { myID, users } from './state.js';
import { withAvatar } from './avatar.js';
import { back } from './app.js';
import { alertMetrics } from './metricchart.js';
@@ -651,7 +652,7 @@ async function assign() {
'aria-checked': String(u.id === inc.assigned_to_id),
onclick: () => closeSheet(u.id),
},
icon('user'),
withAvatar(h('span', { class: 'avatar avatar-sm', text: initial(u.username) }), u.id),
u.id === me ? `${u.username} (you)` : u.username,
onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null,
)))),
+3 -2
View File
@@ -10,6 +10,7 @@ import * as api from './api.js';
import { h, clear, badge, icon, spinner } from './ui.js';
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
import { myID, currentTeam } from './state.js';
import { withAvatar } from './avatar.js';
const view = () => document.getElementById('view-oncall');
@@ -96,7 +97,7 @@ function nowCard() {
return h('div', { class: 'card hero' },
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
h('div', { class: 'hero-top' },
h('div', { class: 'avatar hero-avatar', text: initial(n.username) }),
withAvatar(h('div', { class: 'avatar hero-avatar', text: initial(n.username) }), n.user_id),
h('div', {},
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
until && h('div', { class: 'hero-until', text: until }))));
@@ -143,7 +144,7 @@ function weekCard() {
},
h('span', { class: 'strip-name', text: dayName.format(d) }),
h('span', { class: 'strip-date', text: String(d.getDate()) }),
h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }),
(el => (e ? withAvatar(el, e.user_id) : el))(h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' })),
h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
}
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
+8
View File
@@ -2,6 +2,7 @@
// 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 }
@@ -70,6 +71,7 @@ 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;
@@ -91,4 +93,10 @@ export function reset() {
state.open = [];
state.teams = [];
usersCache = null;
clearAvatars();
}
// After a picture changes: the cached list carries the old avatar_version.
export function forgetUsers() {
usersCache = null;
}
+2 -1
View File
@@ -20,6 +20,7 @@
import * as api from './api.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js';
import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
import { withAvatar } from './avatar.js';
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
const view = () => document.getElementById('view-team');
@@ -466,7 +467,7 @@ function weekSheet(monday, byDate) {
},
h('span', { class: 'rota-num', text: weekdayFmt.format(d) }),
e
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
? withAvatar(h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) }), e.user_id)
: h('span', { class: 'rota-chip none' }));
}));