600e8eec6a
System Health shows every user's name and avatarAt to everyone, but the picture route GET /api/users/:id/avatar sat under the staff-only /users prefix, so a developer got 403 EADMIN and saw only initials. authGuard now lets any signed-in account make that one read (EVERYONE_READS); every other /users path stays staff-only, and the test checks both.
62 lines
2.0 KiB
TypeScript
62 lines
2.0 KiB
TypeScript
/**
|
|
* @file UserAvatar.tsx
|
|
* @description An account's round picture: the signed-in account's own
|
|
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
|
* /api/users/:id/avatar`, any signed-in account), 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, 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>
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { api } from "../../lib/api";
|
|
|
|
export function UserAvatar({
|
|
name,
|
|
avatarAt,
|
|
userId,
|
|
letters = 2,
|
|
className = "h-7 w-7 text-[11px]",
|
|
initialsClassName = "bg-accent/15 text-accent",
|
|
}: {
|
|
name: string;
|
|
avatarAt?: string | null;
|
|
/** Another account's; omitted = the signed-in account's own. */
|
|
userId?: number;
|
|
/** How many letters of the name stand in for a missing picture. */
|
|
letters?: number;
|
|
/** Size and font size: the parts that differ between call sites. */
|
|
className?: string;
|
|
/** The initials' colours. */
|
|
initialsClassName?: string;
|
|
}) {
|
|
const url = userId != null ? api.users.avatarUrl(userId, avatarAt) : api.me.avatarUrl(avatarAt);
|
|
// A broken image falls back to the initials, per URL.
|
|
const [failed, setFailed] = useState<string | null>(null);
|
|
const base = `flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full ${className}`;
|
|
if (url && failed !== url) {
|
|
return (
|
|
<img
|
|
src={url}
|
|
alt=""
|
|
aria-hidden
|
|
draggable={false}
|
|
onError={() => setFailed(url)}
|
|
className={`${base} object-cover`}
|
|
data-testid="user-avatar-img"
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<span
|
|
aria-hidden
|
|
className={`${base} ${initialsClassName} font-semibold uppercase`}
|
|
data-testid="user-avatar-initials"
|
|
>
|
|
{name.slice(0, letters)}
|
|
</span>
|
|
);
|
|
}
|