feat: add page for editing users (#23328)

This commit is contained in:
Kayla はな
2026-03-23 12:42:50 -06:00
committed by GitHub
parent 3b268c95d3
commit 4c9e37b659
18 changed files with 372 additions and 33 deletions
+2
View File
@@ -223,6 +223,7 @@ func UserFromGroupMember(member database.GroupMember) database.User {
QuietHoursSchedule: member.UserQuietHoursSchedule,
Name: member.UserName,
GithubComUserID: member.UserGithubComUserID,
IsServiceAccount: member.UserIsServiceAccount,
}
}
@@ -251,6 +252,7 @@ func UserFromGroupMemberRow(member database.GetGroupMembersByGroupIDPaginatedRow
QuietHoursSchedule: member.UserQuietHoursSchedule,
Name: member.UserName,
GithubComUserID: member.UserGithubComUserID,
IsServiceAccount: member.UserIsServiceAccount,
}
}
+1 -2
View File
@@ -1619,6 +1619,7 @@ CREATE VIEW group_members_expanded AS
users.name AS user_name,
users.github_com_user_id AS user_github_com_user_id,
users.is_system AS user_is_system,
users.is_service_account AS user_is_service_account,
groups.organization_id,
groups.name AS group_name,
all_members.group_id
@@ -1627,8 +1628,6 @@ CREATE VIEW group_members_expanded AS
JOIN groups ON ((groups.id = all_members.group_id)))
WHERE (users.deleted = false);
COMMENT ON VIEW group_members_expanded IS 'Joins group members with user information, organization ID, group name. Includes both regular group members and organization members (as part of the "Everyone" group).';
CREATE TABLE inbox_notifications (
id uuid NOT NULL,
user_id uuid NOT NULL,
@@ -0,0 +1,35 @@
DROP VIEW group_members_expanded;
CREATE VIEW group_members_expanded AS
WITH all_members AS (
SELECT group_members.user_id,
group_members.group_id
FROM group_members
UNION
SELECT organization_members.user_id,
organization_members.organization_id AS group_id
FROM organization_members
)
SELECT users.id AS user_id,
users.email AS user_email,
users.username AS user_username,
users.hashed_password AS user_hashed_password,
users.created_at AS user_created_at,
users.updated_at AS user_updated_at,
users.status AS user_status,
users.rbac_roles AS user_rbac_roles,
users.login_type AS user_login_type,
users.avatar_url AS user_avatar_url,
users.deleted AS user_deleted,
users.last_seen_at AS user_last_seen_at,
users.quiet_hours_schedule AS user_quiet_hours_schedule,
users.name AS user_name,
users.github_com_user_id AS user_github_com_user_id,
users.is_system AS user_is_system,
groups.organization_id,
groups.name AS group_name,
all_members.group_id
FROM ((all_members
JOIN users ON ((users.id = all_members.user_id)))
JOIN groups ON ((groups.id = all_members.group_id)))
WHERE (users.deleted = false);
@@ -0,0 +1,36 @@
DROP VIEW group_members_expanded;
CREATE VIEW group_members_expanded AS
WITH all_members AS (
SELECT group_members.user_id,
group_members.group_id
FROM group_members
UNION
SELECT organization_members.user_id,
organization_members.organization_id AS group_id
FROM organization_members
)
SELECT users.id AS user_id,
users.email AS user_email,
users.username AS user_username,
users.hashed_password AS user_hashed_password,
users.created_at AS user_created_at,
users.updated_at AS user_updated_at,
users.status AS user_status,
users.rbac_roles AS user_rbac_roles,
users.login_type AS user_login_type,
users.avatar_url AS user_avatar_url,
users.deleted AS user_deleted,
users.last_seen_at AS user_last_seen_at,
users.quiet_hours_schedule AS user_quiet_hours_schedule,
users.name AS user_name,
users.github_com_user_id AS user_github_com_user_id,
users.is_system AS user_is_system,
users.is_service_account as user_is_service_account,
groups.organization_id,
groups.name AS group_name,
all_members.group_id
FROM ((all_members
JOIN users ON ((users.id = all_members.user_id)))
JOIN groups ON ((groups.id = all_members.group_id)))
WHERE (users.deleted = false);
+1 -1
View File
@@ -4394,7 +4394,6 @@ type Group struct {
ChatSpendLimitMicros sql.NullInt64 `db:"chat_spend_limit_micros" json:"chat_spend_limit_micros"`
}
// Joins group members with user information, organization ID, group name. Includes both regular group members and organization members (as part of the "Everyone" group).
type GroupMember struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
UserEmail string `db:"user_email" json:"user_email"`
@@ -4412,6 +4411,7 @@ type GroupMember struct {
UserName string `db:"user_name" json:"user_name"`
UserGithubComUserID sql.NullInt64 `db:"user_github_com_user_id" json:"user_github_com_user_id"`
UserIsSystem bool `db:"user_is_system" json:"user_is_system"`
UserIsServiceAccount bool `db:"user_is_service_account" json:"user_is_service_account"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
GroupName string `db:"group_name" json:"group_name"`
GroupID uuid.UUID `db:"group_id" json:"group_id"`
+8 -4
View File
@@ -7293,7 +7293,7 @@ func (q *sqlQuerier) DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteG
}
const getGroupMembers = `-- name: GetGroupMembers :many
SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, user_is_system, organization_id, group_name, group_id FROM group_members_expanded
SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, user_is_system, user_is_service_account, organization_id, group_name, group_id FROM group_members_expanded
WHERE CASE
WHEN $1::bool THEN TRUE
ELSE
@@ -7327,6 +7327,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context, includeSystem bool) ([
&i.UserName,
&i.UserGithubComUserID,
&i.UserIsSystem,
&i.UserIsServiceAccount,
&i.OrganizationID,
&i.GroupName,
&i.GroupID,
@@ -7345,7 +7346,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context, includeSystem bool) ([
}
const getGroupMembersByGroupID = `-- name: GetGroupMembersByGroupID :many
SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, user_is_system, organization_id, group_name, group_id
SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, user_is_system, user_is_service_account, organization_id, group_name, group_id
FROM group_members_expanded
WHERE group_id = $1
-- Filter by system type
@@ -7387,6 +7388,7 @@ func (q *sqlQuerier) GetGroupMembersByGroupID(ctx context.Context, arg GetGroupM
&i.UserName,
&i.UserGithubComUserID,
&i.UserIsSystem,
&i.UserIsServiceAccount,
&i.OrganizationID,
&i.GroupName,
&i.GroupID,
@@ -7406,7 +7408,7 @@ func (q *sqlQuerier) GetGroupMembersByGroupID(ctx context.Context, arg GetGroupM
const getGroupMembersByGroupIDPaginated = `-- name: GetGroupMembersByGroupIDPaginated :many
SELECT
user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, user_is_system, organization_id, group_name, group_id, COUNT(*) OVER() AS count
user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, user_is_system, user_is_service_account, organization_id, group_name, group_id, COUNT(*) OVER() AS count
FROM
group_members_expanded
WHERE
@@ -7544,6 +7546,7 @@ type GetGroupMembersByGroupIDPaginatedRow struct {
UserName string `db:"user_name" json:"user_name"`
UserGithubComUserID sql.NullInt64 `db:"user_github_com_user_id" json:"user_github_com_user_id"`
UserIsSystem bool `db:"user_is_system" json:"user_is_system"`
UserIsServiceAccount bool `db:"user_is_service_account" json:"user_is_service_account"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
GroupName string `db:"group_name" json:"group_name"`
GroupID uuid.UUID `db:"group_id" json:"group_id"`
@@ -7592,6 +7595,7 @@ func (q *sqlQuerier) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg
&i.UserName,
&i.UserGithubComUserID,
&i.UserIsSystem,
&i.UserIsServiceAccount,
&i.OrganizationID,
&i.GroupName,
&i.GroupID,
@@ -16634,7 +16638,7 @@ FROM
(
-- Select all groups this user is a member of. This will also include
-- the "Everyone" group for organizations the user is a member of.
SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, user_is_system, organization_id, group_name, group_id FROM group_members_expanded
SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, user_is_system, user_is_service_account, organization_id, group_name, group_id FROM group_members_expanded
WHERE
$1 = user_id AND
$2 = group_members_expanded.organization_id
+11
View File
@@ -207,6 +207,17 @@ The following filters are supported:
RFC3339Nano format.
- `login_type` - Represents the login type of the user. Refer to the [LoginType documentation](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#LoginType) for a list of supported values
## Edit a user's profile
To edit a user's display name or username with the web UI:
1. Log in as a user admin.
2. Go to **Users**
3. Find the user whose details you would like to edit
4. Select **Edit** from the actions menu
5. Make any desired changes
6. Click **Save**
## Retrieve your list of Coder users
<div class="tabs">
+7 -7
View File
@@ -549,6 +549,13 @@ class ApiMethods {
return response.data;
};
getUser = async (usernameOrId: string) => {
const response = await this.axios.get<TypesGen.User>(
`/api/v2/users/${encodeURIComponent(usernameOrId)}`,
);
return response.data;
};
getUserParameters = async (templateID: string) => {
const response = await this.axios.get<TypesGen.UserParameter[]>(
`/api/v2/users/me/autofill-parameters?template_id=${templateID}`,
@@ -639,13 +646,6 @@ class ApiMethods {
return response.data;
};
getUser = async (userIdOrName: string): Promise<TypesGen.User> => {
const response = await this.axios.get<TypesGen.User>(
`/api/v2/users/${encodeURIComponent(userIdOrName)}`,
);
return response.data;
};
/**
* Get users for workspace owner selection. Requires
* permission to create workspaces for other users in the
+9 -9
View File
@@ -33,15 +33,6 @@ export function usersKey(req: UsersRequest) {
return ["users", req] as const;
}
export const userByNameKey = (username: string) => ["user", username] as const;
export const userByName = (username: string): UseQueryOptions<User> => {
return {
queryKey: userByNameKey(username),
queryFn: () => API.getUser(username),
};
};
export function paginatedUsers(
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<GetUsersResponse, UsersRequest> {
@@ -163,6 +154,15 @@ export const me = (metadata: MetadataState<User>) => {
});
};
export const userKey = (usernameOrId: string) => ["user", usernameOrId];
export const user = (usernameOrId: string) => {
return {
queryKey: userKey(usernameOrId),
queryFn: () => API.getUser(usernameOrId),
};
};
export function apiKey(): UseQueryOptions<GenerateAPIKeyResponse> {
return {
queryKey: [...meKey, "apiKey"],
@@ -2,7 +2,7 @@ import { MockUserOwner } from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { userByNameKey } from "api/queries/users";
import { userKey } from "api/queries/users";
import type * as TypesGen from "api/typesGenerated";
import dayjs from "dayjs";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
@@ -481,7 +481,7 @@ export const UsageUserDrillIn: Story = {
canManageChatModelConfigs: true,
},
parameters: {
queries: [{ key: userByNameKey("user-1"), data: mockUserProfile }],
queries: [{ key: userKey("user-1"), data: mockUserProfile }],
},
beforeEach: () => {
setupUsageSpies();
@@ -515,7 +515,7 @@ export const UsageUserDrillInAndBack: Story = {
canManageChatModelConfigs: true,
},
parameters: {
queries: [{ key: userByNameKey("user-1"), data: mockUserProfile }],
queries: [{ key: userKey("user-1"), data: mockUserProfile }],
},
beforeEach: () => {
setupUsageSpies();
@@ -11,7 +11,7 @@ import {
updateChatWorkspaceTTL,
updateUserChatCustomPrompt,
} from "api/queries/chats";
import { userByName } from "api/queries/users";
import { user } from "api/queries/users";
import type * as TypesGen from "api/typesGenerated";
import { AvatarData } from "components/Avatar/AvatarData";
import { Button } from "components/Button/Button";
@@ -236,7 +236,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
const selectedUserId = searchParams.get("user");
const selectedUserQuery = useQuery({
...userByName(selectedUserId ?? ""),
...user(selectedUserId ?? ""),
enabled: selectedUserId !== null,
});
const selectedUser = selectedUserQuery.data ?? null;
@@ -0,0 +1,56 @@
import { mockApiError } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { action } from "storybook/actions";
import { EditUserForm } from "./EditUserForm";
const meta: Meta<typeof EditUserForm> = {
title: "pages/EditUserPage",
component: EditUserForm,
args: {
onCancel: action("cancel"),
onSubmit: action("submit"),
isLoading: false,
initialValues: {
username: "john-doe",
name: "John Doe",
},
},
};
export default meta;
type Story = StoryObj<typeof EditUserForm>;
export const Ready: Story = {};
export const NoDisplayName: Story = {
args: {
initialValues: {
username: "jane-doe",
name: "",
},
},
};
export const FormError: Story = {
args: {
error: mockApiError({
validations: [
{ field: "username", detail: "Username is already taken." },
],
}),
},
};
export const GeneralError: Story = {
args: {
error: mockApiError({
message: "Failed to update user profile.",
}),
},
};
export const Loading: Story = {
args: {
isLoading: true,
},
};
@@ -0,0 +1,98 @@
import { hasApiFieldErrors, isApiError } from "api/errors";
import type { UpdateUserProfileRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
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 { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import type { FC } from "react";
import {
displayNameValidator,
getFormHelpers,
nameValidator,
onChangeTrimmed,
} from "utils/formUtils";
import * as Yup from "yup";
const validationSchema = Yup.object({
username: nameValidator("Username"),
name: displayNameValidator("Full name"),
});
interface EditUserFormProps {
error?: unknown;
isLoading: boolean;
initialValues: UpdateUserProfileRequest;
onSubmit: (values: UpdateUserProfileRequest) => void;
onCancel: () => void;
}
export const EditUserForm: FC<EditUserFormProps> = ({
error,
isLoading,
initialValues,
onSubmit,
onCancel,
}) => {
const form = useFormik<UpdateUserProfileRequest>({
initialValues,
validationSchema,
onSubmit,
enableReinitialize: true,
});
const getFieldHelpers = getFormHelpers(form, error);
return (
<FullPageForm title="Edit user">
{isApiError(error) && !hasApiFieldErrors(error) && (
<ErrorAlert error={error} className="mb-8" />
)}
<form onSubmit={form.handleSubmit} autoComplete="off">
<div className="flex flex-col gap-6">
<FormField
field={getFieldHelpers("username")}
label="Username"
id="username"
name="username"
value={form.values.username}
onChange={onChangeTrimmed(form)}
onBlur={form.handleBlur}
autoComplete="username"
autoFocus
/>
<FormField
field={getFieldHelpers("name")}
label={
<>
Full name{" "}
<span className="font-normal text-content-secondary">
(optional)
</span>
</>
}
id="name"
name="name"
value={form.values.name}
onChange={form.handleChange}
onBlur={form.handleBlur}
autoComplete="name"
/>
</div>
<FormFooter className="mt-8">
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
<Spinner loading={isLoading} />
Save
</Button>
</FormFooter>
</form>
</FullPageForm>
);
};
@@ -0,0 +1,83 @@
import { getErrorDetail, getErrorMessage } from "api/errors";
import { updateProfile, user } from "api/queries/users";
import type { UpdateUserProfileRequest } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { pageTitle } from "utils/page";
import { isUUID } from "utils/uuid";
import { EditUserForm } from "./EditUserForm";
const EditUserPage: FC = () => {
const { user: usernameOrId } = useParams() as { user: string };
const navigate = useNavigate();
const queryClient = useQueryClient();
const userQuery = useQuery(user(usernameOrId));
const updateProfileMutation = useMutation(
updateProfile(userQuery.data?.id ?? ""),
);
if (!userQuery.data) {
return <Loader />;
}
const userData = userQuery.data;
const handleSubmit = async (values: UpdateUserProfileRequest) => {
const mutation = updateProfileMutation.mutateAsync(values, {
onSuccess: (updatedUser) => {
// Invalidate the user cache so other parts of the UI reflect the change.
void queryClient.invalidateQueries({
queryKey: ["user", usernameOrId],
});
void queryClient.invalidateQueries({ queryKey: ["users"] });
// If the URL currently uses the username (not a UUID) and the username
// has changed, rewrite the URL so the page doesn't 404 on refresh.
if (!isUUID(usernameOrId) && updatedUser.username !== usernameOrId) {
navigate(`../${updatedUser.username}`, {
relative: "path",
replace: true,
});
}
},
});
toast.promise(mutation, {
loading: `Saving user "${values.username}"…`,
success: `User "${values.username}" updated successfully.`,
error: (e) => ({
message: getErrorMessage(
e,
`Failed to update user "${values.username}".`,
),
description: getErrorDetail(e),
}),
});
};
return (
<Margins>
<title>{pageTitle("Edit User", `${userData.username}`)}</title>
<EditUserForm
error={updateProfileMutation.error}
isLoading={updateProfileMutation.isPending}
initialValues={{
username: userData.username,
name: userData.name ?? "",
}}
onSubmit={handleSubmit}
onCancel={() => {
navigate("..", { relative: "path" });
}}
/>
</Margins>
);
};
export default EditUserPage;
@@ -203,7 +203,9 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
/>
}
title={member.username}
subtitle={member.email}
subtitle={
member.is_service_account ? "Service Account" : member.email
}
/>
</TableCell>
<TableCell
@@ -33,6 +33,7 @@ import {
UserLockIcon,
} from "lucide-react";
import type { FC } from "react";
import { useNavigate } from "react-router";
import { UserRoleCell } from "../../OrganizationSettingsPage/UserTable/UserRoleCell";
import { UserGroupsCell } from "./UserGroupsCell";
@@ -85,6 +86,8 @@ export const UsersTableBody: FC<UsersTableBodyProps> = ({
oidcRoleSyncEnabled,
groupsByUserId,
}) => {
const navigate = useNavigate();
return (
<ChooseOne>
<Cond condition={Boolean(isLoading)}>
@@ -224,6 +227,10 @@ export const UsersTableBody: FC<UsersTableBodyProps> = ({
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => navigate(user.username)}>
Edit
</DropdownMenuItem>
{user.login_type === "password" && (
<DropdownMenuItem
onClick={() => onResetUserPassword(user)}
+6 -2
View File
@@ -71,6 +71,7 @@ const WorkspaceProxyPage = lazy(
const CreateUserPage = lazy(
() => import("./pages/CreateUserPage/CreateUserPage"),
);
const EditUserPage = lazy(() => import("./pages/EditUserPage/EditUserPage"));
const WorkspaceBuildPage = lazy(
() => import("./pages/WorkspaceBuildPage/WorkspaceBuildPage"),
);
@@ -551,8 +552,11 @@ export const router = createBrowserRouter(
</Route>
</Route>
<Route path="users" element={<UsersPage />} />
<Route path="users/create" element={<CreateUserPage />} />
<Route path="users">
<Route index element={<UsersPage />} />
<Route path="create" element={<CreateUserPage />} />
<Route path=":user" element={<EditUserPage />} />
</Route>
{groupsRouter()}
+4 -2
View File
@@ -1,3 +1,5 @@
export const pageTitle = (...crumbs: string[]): string => {
return [...crumbs, "Coder"].join(" - ");
export const pageTitle = (
...crumbs: Array<string | boolean | undefined | null>
): string => {
return [...crumbs, "Coder"].filter(Boolean).join(" - ");
};