Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -917,11 +917,19 @@ func (api *API) putUserProfile(rw http.ResponseWriter, r *http.Request) {
return
}

// Avatars for password and none login types are managed manually. For
// other login types the avatar is synced from the identity provider on
// login, so we preserve the existing value and ignore any submitted one.
avatarURL := user.AvatarURL
if user.LoginType == database.LoginTypePassword || user.LoginType == database.LoginTypeNone {
avatarURL = params.AvatarURL
}

updatedUserProfile, err := api.Database.UpdateUserProfile(ctx, database.UpdateUserProfileParams{
ID: user.ID,
Email: user.Email,
Name: params.Name,
AvatarURL: user.AvatarURL,
AvatarURL: avatarURL,
Username: params.Username,
UpdatedAt: dbtime.Now(),
})
Expand Down
62 changes: 62 additions & 0 deletions coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,68 @@ func TestUpdateUserProfile(t *testing.T) {
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
})

t.Run("UpdateAvatar", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)

// The first user is a password user, so the avatar is editable.
const newAvatar = "/emojis/1f600.png"
userProfile, err := client.UpdateUserProfile(ctx, codersdk.Me, codersdk.UpdateUserProfileRequest{
Username: me.Username,
Name: me.Name,
AvatarURL: newAvatar,
})
require.NoError(t, err)
require.Equal(t, newAvatar, userProfile.AvatarURL)
})

t.Run("IgnoresAvatarForSSOUser", func(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, nil)
// The first user is an owner and can update other users' profiles.
coderdtest.CreateFirstUser(t, client)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

// Avatars for SSO users are synced from the identity provider on login,
// so a submitted avatar must be ignored and the existing one preserved.
ssoUser := dbgen.User(t, db, database.User{
Email: "[email protected]",
Username: "sso-avatar",
LoginType: database.LoginTypeOIDC,
})

// dbgen.User does not persist the avatar at creation, so set it directly
// to emulate an avatar synced from the identity provider.
const idpAvatar = "https://idp.example.com/avatar.png"
//nolint:gocritic // Test setup requires a system context to set the avatar.
ssoUser, err := db.UpdateUserProfile(dbauthz.AsSystemRestricted(ctx), database.UpdateUserProfileParams{
ID: ssoUser.ID,
Email: ssoUser.Email,
Name: ssoUser.Name,
AvatarURL: idpAvatar,
Username: ssoUser.Username,
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)

userProfile, err := client.UpdateUserProfile(ctx, ssoUser.ID.String(), codersdk.UpdateUserProfileRequest{
Username: ssoUser.Username,
Name: ssoUser.Name,
AvatarURL: "/emojis/1f600.png",
})
require.NoError(t, err)
require.Equal(t, idpAvatar, userProfile.AvatarURL)
})
}

func TestUpdateUserPassword(t *testing.T) {
Expand Down
4 changes: 4 additions & 0 deletions codersdk/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ func (r *CreateUserRequestWithOrgs) UnmarshalJSON(data []byte) error {
type UpdateUserProfileRequest struct {
Username string `json:"username" validate:"required,username"`
Name string `json:"name" validate:"user_real_name"`
// 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.
AvatarURL string `json:"avatar_url" format:"uri"`
}

type ValidateUserPasswordRequest struct {
Expand Down
10 changes: 6 additions & 4 deletions docs/reference/api/schemas.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/reference/api/users.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions site/src/pages/EditUserPage/EditUserForm.stories.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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: "",
},
},
};
Expand All @@ -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({
Expand Down
15 changes: 15 additions & 0 deletions site/src/pages/EditUserPage/EditUserForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this one?

canEditAvatar: boolean;
onSubmit: (values: UpdateUserProfileRequest) => void;
onCancel: () => void;
}
Expand All @@ -33,6 +37,7 @@ export const EditUserForm: FC<EditUserFormProps> = ({
error,
isLoading,
initialValues,
canEditAvatar,
onSubmit,
onCancel,
}) => {
Expand Down Expand Up @@ -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">
Expand Down
4 changes: 4 additions & 0 deletions site/src/pages/EditUserPage/EditUserPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const meta: Meta<typeof AccountForm> = {
initialValues: {
username: "test-user",
name: "Test User",
avatar_url: "",
},
updateProfileError: undefined,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe("AccountForm", () => {
const mockInitialValues: UpdateUserProfileRequest = {
username: MockUserMember.username,
name: MockUserMember.name ?? MockUserMember.username,
avatar_url: MockUserMember.avatar_url ?? "",
};

// When
Expand Down Expand Up @@ -42,6 +43,7 @@ describe("AccountForm", () => {
const mockInitialValues: UpdateUserProfileRequest = {
username: MockUserMember.username,
name: MockUserMember.name ?? MockUserMember.username,
avatar_url: MockUserMember.avatar_url ?? "",
};

// When
Expand All @@ -65,6 +67,7 @@ describe("AccountForm", () => {
const mockInitialValues: UpdateUserProfileRequest = {
username: MockUserMember.username,
name: MockUserMember.name ?? MockUserMember.username,
avatar_url: MockUserMember.avatar_url ?? "",
};

// When
Expand Down
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
10 changes: 10 additions & 0 deletions site/src/theme/externalImages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 9 additions & 1 deletion site/src/theme/externalImages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading