fix(users): every user row shows its avatar, not only its initials

System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own
picture with no user id, and the host stats never carried avatarAt, so every
card fell back to letters. The groups now carry avatarAt and the card passes
userId. The user editor's header, the pool board's member chips and the pool
member picker drew hand-made initials; they use UserAvatar too.
This commit is contained in:
2026-10-29 13:40:28 +07:00
parent db5a7776cc
commit f1a90e120e
11 changed files with 46 additions and 18 deletions
+6 -1
View File
@@ -683,7 +683,12 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
aria-expanded={isOpen} aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default" className="flex w-full items-center gap-2 text-left disabled:cursor-default"
> >
<UserAvatar name={name} className="h-6 w-6 text-[10px]" /> <UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary"> <span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name} {name}
</span> </span>
@@ -69,6 +69,7 @@ const stats: HostStats = {
const api = vi.hoisted(() => ({ const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() }, host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null }, me: { avatarUrl: () => null },
users: { avatarUrl: () => null },
})); }));
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
+9 -1
View File
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import type { AuthUser, ServerUserUsage } from "../../lib/api"; import type { AuthUser, ServerUserUsage } from "../../lib/api";
import { Checkbox } from "../Checkbox"; import { Checkbox } from "../Checkbox";
import { UserAvatar } from "./UserAvatar";
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"]; export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
/** A role, every user, or the users in no pool group. */ /** A role, every user, or the users in no pool group. */
@@ -168,7 +169,14 @@ export function MemberPicker({
checked={chosen.has(u.id)} checked={chosen.has(u.id)}
onChange={(on) => setMany([u.id], on)} onChange={(on) => setMany([u.id], on)}
label={ label={
<span> <span className="inline-flex items-center gap-1.5">
<UserAvatar
name={u.displayName || u.username}
userId={u.id}
avatarAt={u.avatarAt}
letters={1}
className="h-5 w-5 text-[10px]"
/>
{u.username} {u.username}
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>} {u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
{note?.(u)} {note?.(u)}
+8 -6
View File
@@ -36,6 +36,7 @@ import { toast } from "../../lib/toast";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { MemberPicker } from "./MemberPicker"; import { MemberPicker } from "./MemberPicker";
import { UserAvatar } from "./UserAvatar";
/** Pool tools with their labels, in display order. */ /** Pool tools with their labels, in display order. */
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [ export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
@@ -363,12 +364,13 @@ export function PoolGroups({
} ${isDragged(drag) ? "opacity-40" : ""}`} } ${isDragged(drag) ? "opacity-40" : ""}`}
data-testid="pool-member" data-testid="pool-member"
> >
<span <UserAvatar
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent/15 text-[9px] font-semibold uppercase text-accent" name={name}
aria-hidden userId={id}
> avatarAt={u?.avatarAt}
{name.charAt(0)} letters={1}
</span> className="h-4 w-4 text-[9px]"
/>
{name} {name}
{u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>} {u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>}
{isAdmin && from && ( {isAdmin && from && (
+3 -1
View File
@@ -4,7 +4,9 @@
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET * (`GET /api/me/avatar`) or, with `userId`, another's (`GET
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one — * /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
* or when the image fails to load — the first letters of its name. Used by the * or when the image fails to load — the first letters of its name. Used by the
* sidebar's account row, its workspace owner groups and Settings → Account. * sidebar's account row, its workspace owner groups, Settings → Account, the
* Users page and its editor, the pool board and member picker, and System
* Health's per-user CPU/RAM card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -33,6 +33,7 @@ import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { passwordProblems } from "../../lib/passwordRules"; import { passwordProblems } from "../../lib/passwordRules";
import { PasswordRules } from "./PasswordRules"; import { PasswordRules } from "./PasswordRules";
import { UserAvatar } from "./UserAvatar";
import { useDialog } from "../../hooks/useDialog"; import { useDialog } from "../../hooks/useDialog";
import { Switch } from "../Switch"; import { Switch } from "../Switch";
import { Checkbox } from "../Checkbox"; import { Checkbox } from "../Checkbox";
@@ -243,12 +244,12 @@ export function UserEditDialog({
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl" className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
> >
<div className="flex items-start gap-3 border-b border-border px-5 py-4"> <div className="flex items-start gap-3 border-b border-border px-5 py-4">
<span <UserAvatar
aria-hidden name={shown}
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent" userId={user.id}
> avatarAt={user.avatarAt}
{shown.slice(0, 2)} className="h-10 w-10 text-sm"
</span> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary"> <h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
{t("edit.title", { name: user.displayName || user.username })} {t("edit.title", { name: user.displayName || user.username })}
@@ -33,7 +33,7 @@ const accountPool = vi.hoisted(() => ({
updateGroup: vi.fn(), updateGroup: vi.fn(),
deleteGroup: vi.fn(), deleteGroup: vi.fn(),
})); }));
const users = vi.hoisted(() => ({ list: vi.fn() })); const users = vi.hoisted(() => ({ list: vi.fn(), avatarUrl: () => null }));
const aiTools = vi.hoisted(() => ({ const aiTools = vi.hoisted(() => ({
list: vi.fn(), list: vi.fn(),
ompSharing: vi.fn(), ompSharing: vi.fn(),
@@ -13,7 +13,12 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import type { ServerUserUsage } from "../../../lib/api"; import type { ServerUserUsage } from "../../../lib/api";
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() })); const users = vi.hoisted(() => ({
get: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
avatarUrl: () => null,
}));
const confirmDialog = vi.hoisted(() => vi.fn()); const confirmDialog = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog })); vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
const lanes = vi.hoisted(() => ({ list: vi.fn() })); const lanes = vi.hoisted(() => ({ list: vi.fn() }));
@@ -16,6 +16,7 @@ const users = vi.hoisted(() => ({
setQuotaDefault: vi.fn(), setQuotaDefault: vi.fn(),
update: vi.fn(), update: vi.fn(),
get: vi.fn(), get: vi.fn(),
avatarUrl: () => null,
})); }));
const lanes = vi.hoisted(() => ({ list: vi.fn() })); const lanes = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
+2
View File
@@ -684,6 +684,8 @@ export interface HostUsageGroup {
username?: string; username?: string;
/** The user's display name, null when none is set (absent from an older server). */ /** The user's display name, null when none is set (absent from an older server). */
displayName?: string | null; displayName?: string | null;
/** When the user's avatar was last set (null = none; absent from an older server). */
avatarAt?: string | null;
/** % of the whole machine's CPU (100 = every core busy) over the sample. */ /** % of the whole machine's CPU (100 = every core busy) over the sample. */
cpu: number; cpu: number;
/** Resident memory, bytes (shared pages count in every process). */ /** Resident memory, bytes (shared pages count in every process). */
+2 -1
View File
@@ -555,12 +555,13 @@ async function usage(t0, t1, p0, p1, mem) {
const round = (n) => Math.round(n * 10) / 10; const round = (n) => Math.round(n * 10) / 10;
/** A user's group fields: id, username and display name (every viewer reads them). */ /** A user's group fields: id, username, display name and avatar version (every viewer reads them). */
const userExtra = (u) => ({ const userExtra = (u) => ({
kind: "user", kind: "user",
userId: u.id, userId: u.id,
username: u.username, username: u.username,
displayName: u.displayName ?? null, displayName: u.displayName ?? null,
avatarAt: u.avatarAt ?? null,
}); });
async function collect() { async function collect() {