mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add page for editing users (#23328)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+1
-2
@@ -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);
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()}
|
||||
|
||||
|
||||
@@ -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(" - ");
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user