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:
@@ -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"
|
||||
>
|
||||
<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">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
@@ -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<Record<string, unknown>>();
|
||||
|
||||
@@ -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={
|
||||
<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.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
|
||||
{note?.(u)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent/15 text-[9px] font-semibold uppercase text-accent"
|
||||
aria-hidden
|
||||
>
|
||||
{name.charAt(0)}
|
||||
</span>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={id}
|
||||
avatarAt={u?.avatarAt}
|
||||
letters={1}
|
||||
className="h-4 w-4 text-[9px]"
|
||||
/>
|
||||
{name}
|
||||
{u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>}
|
||||
{isAdmin && from && (
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||
<span
|
||||
aria-hidden
|
||||
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"
|
||||
>
|
||||
{shown.slice(0, 2)}
|
||||
</span>
|
||||
<UserAvatar
|
||||
name={shown}
|
||||
userId={user.id}
|
||||
avatarAt={user.avatarAt}
|
||||
className="h-10 w-10 text-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
|
||||
{t("edit.title", { name: user.displayName || user.username })}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user