feat: allow editing user avatars (#26652)

Adds an avatar URL field to the admin **Edit user** page, available only
for users whose login type is `password` or `none`.

For identity-provider login types (`github`, `oidc`) the avatar is
synced from the IdP on every login, so the field is hidden and the API
ignores any submitted avatar to avoid confusing overwrites.

The field reuses the same emoji picker + URL input (`IconField`) already
used for template, group, and organization icons.

A follow-up PR will add the same control to the self-service Account
settings page.

<details>
<summary>Implementation plan & decisions</summary>

**Goal:** Let an admin set/clear a user's avatar from the Edit user
page, gated to `password`/`none` login types.

**Backend**
- Add `avatar_url` to `codersdk.UpdateUserProfileRequest`.
- `putUserProfile` applies the submitted avatar only for
`password`/`none`; otherwise it preserves the existing (IdP-synced)
value.
- Regenerated TS types and API docs via `make gen`.

**Frontend**
- `EditUserForm` renders an `IconField` ("Avatar URL") when the login
type allows it.
- `EditUserPage` passes the avatar value and a `canEditAvatar` flag.
- `AccountPage` round-trips `avatar_url` so the shared request type
doesn't wipe avatars on the self-service path.

**Gating** is enforced in both the UI (field hidden) and the backend
(submitted value ignored for IdP login types).

**Tests/stories:** backend `TestUpdateUserProfile` covers apply
(password) and ignore (SSO); `EditUserForm` stories cover the
shown/hidden states with interaction tests.

</details>

---
> Generated by Coder Agents on behalf of @aslilac.
This commit is contained in:
McKayla はな
2026-06-26 16:35:03 -06:00
committed by GitHub
parent 7481e1a5a0
commit efd93027ce
17 changed files with 185 additions and 9 deletions
+6
View File
@@ -9299,6 +9299,12 @@ export interface UpdateUserPreferenceSettingsRequest {
export interface UpdateUserProfileRequest {
readonly username: string;
readonly name: string;
/**
* AvatarURL is only applied for users whose login type is password or
* none. For other login types the avatar is synced from the identity
* provider on login, so a submitted value is ignored.
*/
readonly avatar_url: string;
}
// From codersdk/users.go
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { action } from "storybook/actions";
import { expect, userEvent, within } from "storybook/test";
import { mockApiError } from "#/testHelpers/entities";
import { EditUserForm } from "./EditUserForm";
@@ -10,9 +11,11 @@ const meta: Meta<typeof EditUserForm> = {
onCancel: action("cancel"),
onSubmit: action("submit"),
isLoading: false,
canEditAvatar: true,
initialValues: {
username: "john-doe",
name: "John Doe",
avatar_url: "",
},
},
};
@@ -27,10 +30,45 @@ export const NoDisplayName: Story = {
initialValues: {
username: "jane-doe",
name: "",
avatar_url: "",
},
},
};
export const WithAvatar: Story = {
args: {
initialValues: {
username: "john-doe",
name: "John Doe",
avatar_url: "/emojis/1f600.png",
},
},
};
export const EditAvatar: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const field = canvas.getByLabelText("Avatar URL");
await userEvent.clear(field);
// Typing happens one character at a time, so the value passes through
// incomplete states like "https:" that must not crash the preview.
await userEvent.type(field, "https://example.com/avatar.png");
await expect(field).toHaveValue("https://example.com/avatar.png");
},
};
// The avatar field is hidden for login types whose avatar is synced from an
// identity provider (e.g. github, oidc).
export const CannotEditAvatar: Story = {
args: {
canEditAvatar: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByLabelText("Avatar URL")).not.toBeInTheDocument();
},
};
export const FormError: Story = {
args: {
error: mockApiError({
@@ -8,6 +8,7 @@ import { Button } from "#/components/Button/Button";
import { FormFooter } from "#/components/Form/Form";
import { FormField } from "#/components/FormField/FormField";
import { FullPageForm } from "#/components/FullPageForm/FullPageForm";
import { IconField } from "#/components/IconField/IconField";
import { Spinner } from "#/components/Spinner/Spinner";
import {
displayNameValidator,
@@ -19,12 +20,15 @@ import {
const validationSchema = Yup.object({
username: nameValidator("Username"),
name: displayNameValidator("Full name"),
avatar_url: Yup.string(),
});
interface EditUserFormProps {
error?: unknown;
isLoading: boolean;
initialValues: UpdateUserProfileRequest;
/** Allows hiding the avatar setting when it would be overwritten later by the user's identity provider. */
canEditAvatar: boolean;
onSubmit: (values: UpdateUserProfileRequest) => void;
onCancel: () => void;
}
@@ -33,6 +37,7 @@ export const EditUserForm: FC<EditUserFormProps> = ({
error,
isLoading,
initialValues,
canEditAvatar,
onSubmit,
onCancel,
}) => {
@@ -81,6 +86,16 @@ export const EditUserForm: FC<EditUserFormProps> = ({
onBlur={form.handleBlur}
autoComplete="name"
/>
{canEditAvatar && (
<IconField
{...getFieldHelpers("avatar_url")}
label="Avatar URL"
onChange={onChangeTrimmed(form)}
onPickEmoji={(value) => form.setFieldValue("avatar_url", value)}
fullWidth
/>
)}
</div>
<FormFooter className="mt-8">
@@ -70,7 +70,11 @@ const EditUserPage: FC = () => {
initialValues={{
username: userData.username,
name: userData.name ?? "",
avatar_url: userData.avatar_url ?? "",
}}
canEditAvatar={
userData.login_type === "password" || userData.login_type === "none"
}
onSubmit={handleSubmit}
onCancel={() => {
navigate("..", { relative: "path" });
@@ -11,6 +11,7 @@ const meta: Meta<typeof AccountForm> = {
initialValues: {
username: "test-user",
name: "Test User",
avatar_url: "",
},
updateProfileError: undefined,
},
@@ -14,6 +14,7 @@ describe("AccountForm", () => {
const mockInitialValues: UpdateUserProfileRequest = {
username: MockUserMember.username,
name: MockUserMember.name ?? MockUserMember.username,
avatar_url: MockUserMember.avatar_url ?? "",
};
// When
@@ -42,6 +43,7 @@ describe("AccountForm", () => {
const mockInitialValues: UpdateUserProfileRequest = {
username: MockUserMember.username,
name: MockUserMember.name ?? MockUserMember.username,
avatar_url: MockUserMember.avatar_url ?? "",
};
// When
@@ -65,6 +67,7 @@ describe("AccountForm", () => {
const mockInitialValues: UpdateUserProfileRequest = {
username: MockUserMember.username,
name: MockUserMember.name ?? MockUserMember.username,
avatar_url: MockUserMember.avatar_url ?? "",
};
// When
@@ -1,12 +1,13 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { API } from "#/api/api";
import { mockApiError } from "#/testHelpers/entities";
import { MockUserOwner, mockApiError } from "#/testHelpers/entities";
import { renderWithAuth } from "#/testHelpers/renderHelpers";
import AccountPage from "./AccountPage";
const newData = {
username: "user",
name: "Mr User",
avatar_url: MockUserOwner.avatar_url,
};
const fillAndSubmitForm = async () => {
@@ -33,7 +34,6 @@ describe("AccountPage", () => {
status: "active",
organization_ids: ["123"],
roles: [],
avatar_url: "",
last_seen_at: new Date().toISOString(),
login_type: "password",
has_ai_seat: false,
@@ -38,7 +38,11 @@ const AccountPage: FC = () => {
email={me.email}
updateProfileError={updateProfileError}
isLoading={isUpdatingProfile}
initialValues={{ username: me.username, name: me.name ?? "" }}
initialValues={{
username: me.username,
name: me.name ?? "",
avatar_url: me.avatar_url ?? "",
}}
onSubmit={updateProfile}
/>
</div>
+10
View File
@@ -29,6 +29,16 @@ describe("externalImage parameters", () => {
expect(someoneElsesWidgetsStyles).toBeUndefined();
});
test("incomplete or invalid URLs return no styles", () => {
// A user typing a URL produces invalid intermediate values that
// new URL() would throw on. These must not crash.
for (const value of ["https:", "http:/", "://", "not a url"]) {
expect(
getExternalImageStylesFromUrl(forDarkThemes, value),
).toBeUndefined();
}
});
test("blackWithColor brightness", () => {
const tryCase = (params: string) =>
parseImageParameters(forDarkThemes, params);
+9 -1
View File
@@ -117,7 +117,15 @@ export function getExternalImageStylesFromUrl(
return undefined;
}
const url = new URL(urlString, location.origin);
// While a user types a URL the value can be incomplete or invalid (e.g.
// "https:"). new URL() throws on those, so treat them as having no special
// styles instead of crashing the render.
let url: URL;
try {
url = new URL(urlString, location.origin);
} catch {
return undefined;
}
if (url.search) {
return parseImageParameters(modes, url.search);