diff --git a/client/src/components/health/HostHealth.tsx b/client/src/components/health/HostHealth.tsx index eb3f8726..56c9e57a 100644 --- a/client/src/components/health/HostHealth.tsx +++ b/client/src/components/health/HostHealth.tsx @@ -683,7 +683,12 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo aria-expanded={isOpen} className="flex w-full items-center gap-2 text-left disabled:cursor-default" > - + {name} diff --git a/client/src/components/health/__tests__/HostHealth.test.tsx b/client/src/components/health/__tests__/HostHealth.test.tsx index 55f7a769..35c714ce 100644 --- a/client/src/components/health/__tests__/HostHealth.test.tsx +++ b/client/src/components/health/__tests__/HostHealth.test.tsx @@ -69,6 +69,7 @@ const stats: HostStats = { const api = vi.hoisted(() => ({ host: { stats: vi.fn(), history: vi.fn() }, me: { avatarUrl: () => null }, + users: { avatarUrl: () => null }, })); vi.mock("../../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); diff --git a/client/src/components/users/MemberPicker.tsx b/client/src/components/users/MemberPicker.tsx index 8869ab78..2c6c270b 100644 --- a/client/src/components/users/MemberPicker.tsx +++ b/client/src/components/users/MemberPicker.tsx @@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next"; import { Search } from "lucide-react"; import type { AuthUser, ServerUserUsage } from "../../lib/api"; import { Checkbox } from "../Checkbox"; +import { UserAvatar } from "./UserAvatar"; export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"]; /** A role, every user, or the users in no pool group. */ @@ -168,7 +169,14 @@ export function MemberPicker({ checked={chosen.has(u.id)} onChange={(on) => setMany([u.id], on)} label={ - + + {u.username} {u.disabled && ({t("disabled")})} {note?.(u)} diff --git a/client/src/components/users/PoolGroups.tsx b/client/src/components/users/PoolGroups.tsx index 669ebcc2..ee2597d3 100644 --- a/client/src/components/users/PoolGroups.tsx +++ b/client/src/components/users/PoolGroups.tsx @@ -36,6 +36,7 @@ import { toast } from "../../lib/toast"; import { ConfirmModal } from "../ConfirmModal"; import { AiToolIcon } from "../AiToolIcon"; import { MemberPicker } from "./MemberPicker"; +import { UserAvatar } from "./UserAvatar"; /** Pool tools with their labels, in display order. */ export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [ @@ -363,12 +364,13 @@ export function PoolGroups({ } ${isDragged(drag) ? "opacity-40" : ""}`} data-testid="pool-member" > - - {name.charAt(0)} - + {name} {u && {t(`roles.${u.role}`)}} {isAdmin && from && ( diff --git a/client/src/components/users/UserAvatar.tsx b/client/src/components/users/UserAvatar.tsx index b1fe555b..415f0095 100644 --- a/client/src/components/users/UserAvatar.tsx +++ b/client/src/components/users/UserAvatar.tsx @@ -4,7 +4,9 @@ * (`GET /api/me/avatar`) or, with `userId`, another's (`GET * /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 - * 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ĩ */ diff --git a/client/src/components/users/UserEditDialog.tsx b/client/src/components/users/UserEditDialog.tsx index bf95e0e3..bdcd47d0 100644 --- a/client/src/components/users/UserEditDialog.tsx +++ b/client/src/components/users/UserEditDialog.tsx @@ -33,6 +33,7 @@ import { copyText } from "../../lib/clipboard"; import { isSubmitEnter } from "../../lib/keys"; import { passwordProblems } from "../../lib/passwordRules"; import { PasswordRules } from "./PasswordRules"; +import { UserAvatar } from "./UserAvatar"; import { useDialog } from "../../hooks/useDialog"; import { Switch } from "../Switch"; 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" >
- - {shown.slice(0, 2)} - +

{t("edit.title", { name: user.displayName || user.username })} diff --git a/client/src/components/users/__tests__/AccountPoolCard.test.tsx b/client/src/components/users/__tests__/AccountPoolCard.test.tsx index f658f106..8b95cf3f 100644 --- a/client/src/components/users/__tests__/AccountPoolCard.test.tsx +++ b/client/src/components/users/__tests__/AccountPoolCard.test.tsx @@ -33,7 +33,7 @@ const accountPool = vi.hoisted(() => ({ updateGroup: 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(() => ({ list: vi.fn(), ompSharing: vi.fn(), diff --git a/client/src/components/users/__tests__/UserEditDialog.test.tsx b/client/src/components/users/__tests__/UserEditDialog.test.tsx index 3cdf23b7..d82cda1f 100644 --- a/client/src/components/users/__tests__/UserEditDialog.test.tsx +++ b/client/src/components/users/__tests__/UserEditDialog.test.tsx @@ -13,7 +13,12 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; 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()); vi.mock("../../../lib/confirm", () => ({ confirmDialog })); const lanes = vi.hoisted(() => ({ list: vi.fn() })); diff --git a/client/src/components/users/__tests__/UserQuota.test.tsx b/client/src/components/users/__tests__/UserQuota.test.tsx index 5c05af43..4d6d50a1 100644 --- a/client/src/components/users/__tests__/UserQuota.test.tsx +++ b/client/src/components/users/__tests__/UserQuota.test.tsx @@ -16,6 +16,7 @@ const users = vi.hoisted(() => ({ setQuotaDefault: vi.fn(), update: vi.fn(), get: vi.fn(), + avatarUrl: () => null, })); const lanes = vi.hoisted(() => ({ list: vi.fn() })); vi.mock("../../../lib/api", async (importOriginal) => { diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 7a0a79b1..b99771e4 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -684,6 +684,8 @@ export interface HostUsageGroup { username?: string; /** The user's display name, null when none is set (absent from an older server). */ 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. */ cpu: number; /** Resident memory, bytes (shared pages count in every process). */ diff --git a/server/lib/host-stats.js b/server/lib/host-stats.js index b2180bcf..84f13b20 100644 --- a/server/lib/host-stats.js +++ b/server/lib/host-stats.js @@ -555,12 +555,13 @@ async function usage(t0, t1, p0, p1, mem) { 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) => ({ kind: "user", userId: u.id, username: u.username, displayName: u.displayName ?? null, + avatarAt: u.avatarAt ?? null, }); async function collect() {