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}
|
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>>();
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user