From 4c9e37b659088287ff9b869f99b732fa8ce1a3c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kayla=20=E3=81=AF=E3=81=AA?= Date: Mon, 23 Mar 2026 12:42:50 -0600 Subject: [PATCH] feat: add page for editing users (#23328) --- coderd/database/db2sdk/db2sdk.go | 2 + coderd/database/dump.sql | 3 +- ...8_group_member_is_service_account.down.sql | 35 +++++++ ...448_group_member_is_service_account.up.sql | 36 +++++++ coderd/database/models.go | 2 +- coderd/database/queries.sql.go | 12 ++- docs/admin/users/index.md | 11 +++ site/src/api/api.ts | 14 +-- site/src/api/queries/users.ts | 18 ++-- .../AgentSettingsPageView.stories.tsx | 6 +- .../AgentsPage/AgentSettingsPageView.tsx | 4 +- .../EditUserPage/EditUserForm.stories.tsx | 56 +++++++++++ site/src/pages/EditUserPage/EditUserForm.tsx | 98 +++++++++++++++++++ site/src/pages/EditUserPage/EditUserPage.tsx | 83 ++++++++++++++++ .../src/pages/GroupsPage/GroupMembersPage.tsx | 4 +- .../UsersPage/UsersTable/UsersTableBody.tsx | 7 ++ site/src/router.tsx | 8 +- site/src/utils/page.ts | 6 +- 18 files changed, 372 insertions(+), 33 deletions(-) create mode 100644 coderd/database/migrations/000448_group_member_is_service_account.down.sql create mode 100644 coderd/database/migrations/000448_group_member_is_service_account.up.sql create mode 100644 site/src/pages/EditUserPage/EditUserForm.stories.tsx create mode 100644 site/src/pages/EditUserPage/EditUserForm.tsx create mode 100644 site/src/pages/EditUserPage/EditUserPage.tsx diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 32b1e427e2..77b280ff7b 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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, } } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f1b7f3763b..3a77928e1f 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -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, diff --git a/coderd/database/migrations/000448_group_member_is_service_account.down.sql b/coderd/database/migrations/000448_group_member_is_service_account.down.sql new file mode 100644 index 0000000000..1e890d92da --- /dev/null +++ b/coderd/database/migrations/000448_group_member_is_service_account.down.sql @@ -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); diff --git a/coderd/database/migrations/000448_group_member_is_service_account.up.sql b/coderd/database/migrations/000448_group_member_is_service_account.up.sql new file mode 100644 index 0000000000..f843cd7fbe --- /dev/null +++ b/coderd/database/migrations/000448_group_member_is_service_account.up.sql @@ -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); diff --git a/coderd/database/models.go b/coderd/database/models.go index d24c13486c..80972cf7d2 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -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"` diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index afd9a676c8..c22068e141 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 diff --git a/docs/admin/users/index.md b/docs/admin/users/index.md index 4f6f5049d3..c1c528542b 100644 --- a/docs/admin/users/index.md +++ b/docs/admin/users/index.md @@ -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
diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3e7a7988e6..c63adbd248 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -549,6 +549,13 @@ class ApiMethods { return response.data; }; + getUser = async (usernameOrId: string) => { + const response = await this.axios.get( + `/api/v2/users/${encodeURIComponent(usernameOrId)}`, + ); + return response.data; + }; + getUserParameters = async (templateID: string) => { const response = await this.axios.get( `/api/v2/users/me/autofill-parameters?template_id=${templateID}`, @@ -639,13 +646,6 @@ class ApiMethods { return response.data; }; - getUser = async (userIdOrName: string): Promise => { - const response = await this.axios.get( - `/api/v2/users/${encodeURIComponent(userIdOrName)}`, - ); - return response.data; - }; - /** * Get users for workspace owner selection. Requires * permission to create workspaces for other users in the diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 753b053cce..fc875f3d10 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -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 => { - return { - queryKey: userByNameKey(username), - queryFn: () => API.getUser(username), - }; -}; - export function paginatedUsers( searchParams: URLSearchParams, ): UsePaginatedQueryOptions { @@ -163,6 +154,15 @@ export const me = (metadata: MetadataState) => { }); }; +export const userKey = (usernameOrId: string) => ["user", usernameOrId]; + +export const user = (usernameOrId: string) => { + return { + queryKey: userKey(usernameOrId), + queryFn: () => API.getUser(usernameOrId), + }; +}; + export function apiKey(): UseQueryOptions { return { queryKey: [...meKey, "apiKey"], diff --git a/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx index dd4f1b4077..2279b639e7 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx @@ -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(); diff --git a/site/src/pages/AgentsPage/AgentSettingsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsPageView.tsx index 126af58147..d8adc470d9 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPageView.tsx @@ -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 = ({ now }) => { const selectedUserId = searchParams.get("user"); const selectedUserQuery = useQuery({ - ...userByName(selectedUserId ?? ""), + ...user(selectedUserId ?? ""), enabled: selectedUserId !== null, }); const selectedUser = selectedUserQuery.data ?? null; diff --git a/site/src/pages/EditUserPage/EditUserForm.stories.tsx b/site/src/pages/EditUserPage/EditUserForm.stories.tsx new file mode 100644 index 0000000000..d81b9db8e8 --- /dev/null +++ b/site/src/pages/EditUserPage/EditUserForm.stories.tsx @@ -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 = { + 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; + +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, + }, +}; diff --git a/site/src/pages/EditUserPage/EditUserForm.tsx b/site/src/pages/EditUserPage/EditUserForm.tsx new file mode 100644 index 0000000000..f565ff0b2c --- /dev/null +++ b/site/src/pages/EditUserPage/EditUserForm.tsx @@ -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 = ({ + error, + isLoading, + initialValues, + onSubmit, + onCancel, +}) => { + const form = useFormik({ + initialValues, + validationSchema, + onSubmit, + enableReinitialize: true, + }); + + const getFieldHelpers = getFormHelpers(form, error); + + return ( + + {isApiError(error) && !hasApiFieldErrors(error) && ( + + )} +
+
+ + + + Full name{" "} + + (optional) + + + } + id="name" + name="name" + value={form.values.name} + onChange={form.handleChange} + onBlur={form.handleBlur} + autoComplete="name" + /> +
+ + + + + +
+
+ ); +}; diff --git a/site/src/pages/EditUserPage/EditUserPage.tsx b/site/src/pages/EditUserPage/EditUserPage.tsx new file mode 100644 index 0000000000..5020878e9d --- /dev/null +++ b/site/src/pages/EditUserPage/EditUserPage.tsx @@ -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 ; + } + + 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 ( + + {pageTitle("Edit User", `${userData.username}`)} + + { + navigate("..", { relative: "path" }); + }} + /> + + ); +}; + +export default EditUserPage; diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index f968bb790e..83bcff7f60 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -203,7 +203,9 @@ const GroupMemberRow: FC = ({ /> } title={member.username} - subtitle={member.email} + subtitle={ + member.is_service_account ? "Service Account" : member.email + } /> = ({ oidcRoleSyncEnabled, groupsByUserId, }) => { + const navigate = useNavigate(); + return ( @@ -224,6 +227,10 @@ export const UsersTableBody: FC = ({ )} + navigate(user.username)}> + Edit + + {user.login_type === "password" && ( onResetUserPassword(user)} diff --git a/site/src/router.tsx b/site/src/router.tsx index 3c114b697c..cbc5509571 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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( - } /> - } /> + + } /> + } /> + } /> + {groupsRouter()} diff --git a/site/src/utils/page.ts b/site/src/utils/page.ts index 8fb544adb4..2dca4aec28 100644 --- a/site/src/utils/page.ts +++ b/site/src/utils/page.ts @@ -1,3 +1,5 @@ -export const pageTitle = (...crumbs: string[]): string => { - return [...crumbs, "Coder"].join(" - "); +export const pageTitle = ( + ...crumbs: Array +): string => { + return [...crumbs, "Coder"].filter(Boolean).join(" - "); };