From 8ba87d54ab3306b739fec18ed168fde035190b5c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 25 Nov 2025 17:29:29 +0000 Subject: [PATCH 1/9] feat: add workspace sharing page --- site/src/api/api.ts | 10 + site/src/api/queries/workspaces.ts | 63 +++ .../UserOrGroupOption.tsx | 54 +++ .../UserOrGroupAutocomplete.tsx | 95 ++--- .../UserOrGroupAutocomplete.tsx | 142 +++++++ .../WorkspaceSharingPage.tsx | 106 ++++- .../WorkspaceSharingPageView.tsx | 402 ++++++++++++++++++ 7 files changed, 807 insertions(+), 65 deletions(-) create mode 100644 site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx create mode 100644 site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx create mode 100644 site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 27c58cd520e63..7f25fc9dad8c3 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1901,6 +1901,16 @@ class ApiMethods { return response.data; }; + getWorkspaceACL = async ( + workspaceId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaces/${workspaceId}/acl`, + ); + + return response.data; + }; + updateWorkspaceACL = async ( workspaceId: string, data: TypesGen.UpdateWorkspaceACL, diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 65fdac7715821..a8f7d2c656c6a 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -5,9 +5,11 @@ import type { ProvisionerLogLevel, UsageAppName, Workspace, + WorkspaceACL, WorkspaceAgentLog, WorkspaceBuild, WorkspaceBuildParameter, + WorkspaceRole, WorkspacesRequest, WorkspacesResponse, } from "api/typesGenerated"; @@ -18,6 +20,7 @@ import { } from "modules/workspaces/permissions"; import type { ConnectionStatus } from "pages/TerminalPage/types"; import type { + MutationOptions, QueryClient, QueryOptions, UseMutationOptions, @@ -42,6 +45,66 @@ export const workspaceByOwnerAndName = (owner: string, name: string) => { }; }; +export const workspaceACLKey = (workspaceId: string) => [ + "workspaceAcl", + workspaceId, +]; + +export const workspaceACL = (workspaceId: string) => { + return { + queryKey: workspaceACLKey(workspaceId), + queryFn: () => API.getWorkspaceACL(workspaceId), + } satisfies QueryOptions; +}; + +export const setWorkspaceUserRole = ( + queryClient: QueryClient, +): MutationOptions< + void, + unknown, + { + workspaceId: string; + userId: string; + role: WorkspaceRole; + } +> => { + return { + mutationFn: ({ workspaceId, userId, role }) => + API.updateWorkspaceACL(workspaceId, { + user_roles: { + [userId]: role, + }, + }), + onSuccess: async (_res, { workspaceId }) => { + await queryClient.invalidateQueries({ + queryKey: workspaceACLKey(workspaceId), + }); + }, + }; +}; + +export const setWorkspaceGroupRole = ( + queryClient: QueryClient, +): MutationOptions< + void, + unknown, + { workspaceId: string; groupId: string; role: WorkspaceRole } +> => { + return { + mutationFn: ({ workspaceId, groupId, role }) => + API.updateWorkspaceACL(workspaceId, { + group_roles: { + [groupId]: role, + }, + }), + onSuccess: async (_res, { workspaceId }) => { + await queryClient.invalidateQueries({ + queryKey: workspaceACLKey(workspaceId), + }); + }, + }; +}; + type CreateWorkspaceMutationVariables = CreateWorkspaceRequest & { userId: string; }; diff --git a/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx b/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx new file mode 100644 index 0000000000000..c4d70745fe1dd --- /dev/null +++ b/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx @@ -0,0 +1,54 @@ +import type { Group, ReducedUser, User } from "api/typesGenerated"; +import { AvatarData } from "components/Avatar/AvatarData"; +import type { HTMLAttributes } from "react"; +import { getGroupSubtitle } from "utils/groups"; + +export type UserOrGroupAutocompleteValue = User | ReducedUser | Group | null; + +type UserOption = User | ReducedUser; +type OptionType = UserOption | Group; + +/** + * Type guard to check if the value is a Group. + * Groups have a "members" property that users don't have. + */ +export const isGroup = ( + value: UserOrGroupAutocompleteValue, +): value is Group => { + return value !== null && typeof value === "object" && "members" in value; +}; + +interface UserOrGroupOptionProps { + option: OptionType; + htmlProps: HTMLAttributes; +} + +/** + * Shared render component for user/group autocomplete options. + * Displays avatar, name, and subtitle for both users and groups. + */ +export const UserOrGroupOption = ({ + option, + htmlProps, +}: UserOrGroupOptionProps) => { + const isOptionGroup = isGroup(option); + + return ( +
  • + +
  • + ); +}; + +/** + * Tailwind classes for the autocomplete container. + * Apply to the MUI Autocomplete component. + */ +export const autocompleteClassName = + "w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full"; diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx index 9a7624bf64ad9..d656f845ab100 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx @@ -1,17 +1,20 @@ -import { css } from "@emotion/react"; import Autocomplete from "@mui/material/Autocomplete"; import CircularProgress from "@mui/material/CircularProgress"; import TextField from "@mui/material/TextField"; import { templaceACLAvailable } from "api/queries/templates"; import type { Group, ReducedUser } from "api/typesGenerated"; -import { AvatarData } from "components/Avatar/AvatarData"; +import { + autocompleteClassName, + isGroup, + UserOrGroupOption, +} from "components/UserOrGroupAutocomplete/UserOrGroupOption"; import { useDebouncedFunction } from "hooks/debounce"; import { type ChangeEvent, type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; import { prepareQuery } from "utils/filters"; -import { getGroupSubtitle } from "utils/groups"; export type UserOrGroupAutocompleteValue = ReducedUser | Group | null; +type AutocompleteOption = Exclude; type UserOrGroupAutocompleteProps = { value: UserOrGroupAutocompleteValue; @@ -38,7 +41,7 @@ export const UserOrGroupAutocomplete: FC = ({ enabled: autoComplete.open, placeholderData: keepPreviousData, }); - const options = aclAvailableQuery.data + const options: AutocompleteOption[] = aclAvailableQuery.data ? [ ...aclAvailableQuery.data.groups, ...aclAvailableQuery.data.users, @@ -61,9 +64,9 @@ export const UserOrGroupAutocomplete: FC = ({ ); return ( - noOptionsText="No users or groups found" - value={value} + value={value ?? null} id="user-or-group-autocomplete" open={autoComplete.open} onOpen={() => { @@ -81,68 +84,38 @@ export const UserOrGroupAutocomplete: FC = ({ onChange={(_, newValue) => { onChange(newValue); }} - isOptionEqualToValue={(option, value) => option.id === value.id} + isOptionEqualToValue={(option, optionValue) => + optionValue !== null && option.id === optionValue.id + } getOptionLabel={(option) => isGroup(option) ? option.display_name || option.name : option.email } - renderOption={(props, option) => { - const isOptionGroup = isGroup(option); - - return ( -
  • - -
  • - ); - }} + renderOption={(props, option) => ( + + )} options={options} loading={aclAvailableQuery.isFetching} - css={autoCompleteStyles} + className={autocompleteClassName} renderInput={(params) => ( - <> - - {aclAvailableQuery.isFetching ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - }} - /> - + + {aclAvailableQuery.isFetching ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }} + /> )} /> ); }; - -const isGroup = (value: UserOrGroupAutocompleteValue): value is Group => { - return value !== null && "members" in value; -}; - -const autoCompleteStyles = css` - width: 300px; - - & .MuiFormControl-root { - width: 100%; - } - - & .MuiInputBase-root { - width: 100%; - } -`; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx new file mode 100644 index 0000000000000..dff2a3c61c010 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx @@ -0,0 +1,142 @@ +import Autocomplete from "@mui/material/Autocomplete"; +import CircularProgress from "@mui/material/CircularProgress"; +import TextField from "@mui/material/TextField"; +import { groupsByOrganization } from "api/queries/groups"; +import { users } from "api/queries/users"; +import type { Group, User } from "api/typesGenerated"; +import { + autocompleteClassName, + isGroup, + UserOrGroupOption, +} from "components/UserOrGroupAutocomplete/UserOrGroupOption"; +import { useDebouncedFunction } from "hooks/debounce"; +import { type ChangeEvent, type FC, useState } from "react"; +import { keepPreviousData, useQuery } from "react-query"; +import { prepareQuery } from "utils/filters"; + +type AutocompleteOption = User | Group; +export type UserOrGroupAutocompleteValue = AutocompleteOption | null; + +type ExcludableOption = { id?: string | null } | null; + +type UserOrGroupAutocompleteProps = { + value: UserOrGroupAutocompleteValue; + onChange: (value: UserOrGroupAutocompleteValue) => void; + organizationId: string; + exclude: ExcludableOption[]; +}; + +export const UserOrGroupAutocomplete: FC = ({ + value, + onChange, + organizationId, + exclude, +}) => { + const [autoComplete, setAutoComplete] = useState({ + value: "", + open: false, + }); + + const usersQuery = useQuery({ + ...users({ + q: prepareQuery(encodeURI(autoComplete.value)), + limit: 25, + }), + enabled: autoComplete.open, + placeholderData: keepPreviousData, + }); + + const groupsQuery = useQuery({ + ...groupsByOrganization(organizationId), + enabled: autoComplete.open, + placeholderData: keepPreviousData, + }); + + const filterValue = autoComplete.value.trim().toLowerCase(); + const groupOptions = groupsQuery.data + ? groupsQuery.data.filter((group) => { + if (!filterValue) { + return true; + } + const haystack = `${group.display_name ?? ""} ${group.name}`.trim(); + return haystack.toLowerCase().includes(filterValue); + }) + : []; + + const excludeIds = exclude + .map((optionToExclude) => optionToExclude?.id) + .filter((id): id is string => Boolean(id)); + + const options: AutocompleteOption[] = [ + ...groupOptions, + ...(usersQuery.data?.users ?? []), + ].filter((result) => !excludeIds.includes(result.id)); + + const { debounced: handleFilterChange } = useDebouncedFunction( + (event: ChangeEvent) => { + setAutoComplete((state) => ({ + ...state, + value: event.target.value, + })); + }, + 500, + ); + + return ( + + noOptionsText="No users or groups found" + value={value ?? null} + id="workspace-user-or-group-autocomplete" + open={autoComplete.open} + onOpen={() => { + setAutoComplete((state) => ({ + ...state, + open: true, + })); + }} + onClose={() => { + setAutoComplete({ + value: isGroup(value) + ? value.display_name || value.name + : (value?.email ?? value?.username ?? ""), + open: false, + }); + }} + onChange={(_, newValue) => { + onChange(newValue ?? null); + }} + isOptionEqualToValue={(option, optionValue) => + optionValue !== null && option.id === optionValue.id + } + getOptionLabel={(option) => + isGroup(option) ? option.display_name || option.name : option.email + } + renderOption={(props, option) => ( + + )} + options={options} + loading={usersQuery.isFetching || groupsQuery.isFetching} + className={autocompleteClassName} + renderInput={(params) => ( + + {(usersQuery.isFetching || groupsQuery.isFetching) && ( + + )} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ); +}; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx index 723fccfd0e653..7d69827efda77 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx @@ -1,18 +1,116 @@ -import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { checkAuthorization } from "api/queries/authCheck"; +import { + setWorkspaceGroupRole, + setWorkspaceUserRole, + workspaceACL, +} from "api/queries/workspaces"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; +import type { WorkspacePermissions } from "modules/workspaces/permissions"; +import { workspaceChecks } from "modules/workspaces/permissions"; import type { FC } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { pageTitle } from "utils/page"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; +import { WorkspaceSharingPageView } from "./WorkspaceSharingPageView"; const WorkspaceSharingPage: FC = () => { const workspace = useWorkspaceSettings(); + const queryClient = useQueryClient(); + + const workspaceACLQuery = useQuery(workspaceACL(workspace.id)); + const checks = workspaceChecks(workspace); + const permissionsQuery = useQuery({ + ...checkAuthorization({ checks }), + }); + const permissions = permissionsQuery.data as WorkspacePermissions | undefined; + + const addUserMutation = useMutation(setWorkspaceUserRole(queryClient)); + const updateUserMutation = useMutation(setWorkspaceUserRole(queryClient)); + const removeUserMutation = useMutation(setWorkspaceUserRole(queryClient)); + + const addGroupMutation = useMutation(setWorkspaceGroupRole(queryClient)); + const updateGroupMutation = useMutation(setWorkspaceGroupRole(queryClient)); + const removeGroupMutation = useMutation(setWorkspaceGroupRole(queryClient)); + + const canUpdatePermissions = Boolean(permissions?.updateWorkspace); return ( <> {pageTitle(workspace.name, "Sharing")} - - Sharing - + {workspaceACLQuery.isError && ( + + )} + {permissionsQuery.isError && ( + + )} + + { + await addUserMutation.mutateAsync({ + workspaceId: workspace.id, + userId: user.id, + role, + }); + reset(); + }} + isAddingUser={addUserMutation.isPending} + onUpdateUser={async (user, role) => { + await updateUserMutation.mutateAsync({ + workspaceId: workspace.id, + userId: user.id, + role, + }); + displaySuccess("User role updated successfully!"); + }} + updatingUserId={ + updateUserMutation.isPending + ? updateUserMutation.variables?.userId + : undefined + } + onRemoveUser={async (user) => { + await removeUserMutation.mutateAsync({ + workspaceId: workspace.id, + userId: user.id, + role: "", + }); + displaySuccess("User removed successfully!"); + }} + onAddGroup={async (group, role, reset) => { + await addGroupMutation.mutateAsync({ + workspaceId: workspace.id, + groupId: group.id, + role, + }); + reset(); + }} + isAddingGroup={addGroupMutation.isPending} + onUpdateGroup={async (group, role) => { + await updateGroupMutation.mutateAsync({ + workspaceId: workspace.id, + groupId: group.id, + role, + }); + displaySuccess("Group role updated successfully!"); + }} + updatingGroupId={ + updateGroupMutation.isPending + ? updateGroupMutation.variables?.groupId + : undefined + } + onRemoveGroup={async (group) => { + await removeGroupMutation.mutateAsync({ + workspaceId: workspace.id, + groupId: group.id, + role: "", + }); + displaySuccess("Group removed successfully!"); + }} + /> ); }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx new file mode 100644 index 0000000000000..fd63ce510fc92 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx @@ -0,0 +1,402 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import MenuItem from "@mui/material/MenuItem"; +import Select, { type SelectProps } from "@mui/material/Select"; +import type { + Group, + User, + Workspace, + WorkspaceACL, + WorkspaceGroup, + WorkspaceRole, + WorkspaceUser, +} from "api/typesGenerated"; +import { Avatar } from "components/Avatar/Avatar"; +import { AvatarData } from "components/Avatar/AvatarData"; +import { Button } from "components/Button/Button"; +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { Spinner } from "components/Spinner/Spinner"; +import { Stack } from "components/Stack/Stack"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "components/Table/Table"; +import { TableLoader } from "components/TableLoader/TableLoader"; +import { EllipsisVertical, UserPlusIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { getGroupSubtitle } from "utils/groups"; +import { + UserOrGroupAutocomplete, + type UserOrGroupAutocompleteValue, +} from "./UserOrGroupAutocomplete"; + +type AddWorkspaceUserOrGroupProps = { + organizationID: string; + isLoading: boolean; + workspaceACL: WorkspaceACL | undefined; + onSubmit: ( + value: WorkspaceUser | Group | ({ role: WorkspaceRole } & User), + role: WorkspaceRole, + reset: () => void, + ) => void; +}; + +const AddWorkspaceUserOrGroup: FC = ({ + organizationID, + isLoading, + workspaceACL, + onSubmit, +}) => { + const [selectedOption, setSelectedOption] = + useState(null); + const [selectedRole, setSelectedRole] = useState("use"); + const excludeFromAutocomplete = workspaceACL + ? [...workspaceACL.group, ...workspaceACL.users] + : []; + + const resetValues = () => { + setSelectedOption(null); + setSelectedRole("use"); + }; + + return ( +
    { + event.preventDefault(); + + if (selectedOption && selectedRole) { + onSubmit( + { + ...selectedOption, + role: selectedRole, + }, + selectedRole, + resetValues, + ); + } + }} + > + + { + setSelectedOption(newValue); + }} + /> + + + + + +
    + ); +}; + +const RoleSelect: FC = (props) => { + return ( + + ); +}; + +interface WorkspaceSharingPageViewProps { + workspace: Workspace; + workspaceACL: WorkspaceACL | undefined; + canUpdatePermissions: boolean; + // User + onAddUser: ( + user: WorkspaceUser | ({ role: WorkspaceRole } & User), + role: WorkspaceRole, + reset: () => void, + ) => void; + isAddingUser: boolean; + onUpdateUser: (user: WorkspaceUser, role: WorkspaceRole) => void; + updatingUserId: WorkspaceUser["id"] | undefined; + onRemoveUser: (user: WorkspaceUser) => void; + // Group + onAddGroup: (group: Group, role: WorkspaceRole, reset: () => void) => void; + isAddingGroup: boolean; + onUpdateGroup: (group: WorkspaceGroup, role: WorkspaceRole) => void; + updatingGroupId?: WorkspaceGroup["id"] | undefined; + onRemoveGroup: (group: Group) => void; +} + +export const WorkspaceSharingPageView: FC = ({ + workspace, + workspaceACL, + canUpdatePermissions, + // User + onAddUser, + isAddingUser, + updatingUserId, + onUpdateUser, + onRemoveUser, + // Group + onAddGroup, + isAddingGroup, + updatingGroupId, + onUpdateGroup, + onRemoveGroup, +}) => { + const isEmpty = Boolean( + workspaceACL && + workspaceACL.users.length === 0 && + workspaceACL.group.length === 0, + ); + + return ( + <> + + Sharing + + + + {canUpdatePermissions && ( + + "members" in value + ? onAddGroup(value, role, resetAutocomplete) + : onAddUser(value, role, resetAutocomplete) + } + /> + )} + + + + Member + Role + + + + + + + + + + + + + + + + + {workspaceACL?.group.map((group) => ( + + + + } + title={group.display_name || group.name} + subtitle={getGroupSubtitle(group)} + /> + + + + + { + onUpdateGroup( + group, + event.target.value as WorkspaceRole, + ); + }} + /> + + +
    {group.role}
    +
    +
    +
    + + + {canUpdatePermissions && ( + + + + + + onRemoveGroup(group)} + > + Remove + + + + )} + +
    + ))} + + {workspaceACL?.users.map((user) => ( + + + + + + + + { + onUpdateUser( + user, + event.target.value as WorkspaceRole, + ); + }} + /> + + +
    {user.role}
    +
    +
    +
    + + + {canUpdatePermissions && ( + + + + + + onRemoveUser(user)} + > + Remove + + + + )} + +
    + ))} +
    +
    +
    +
    +
    + + ); +}; + +const styles = { + select: { + fontSize: 14, + width: 100, + }, + updateSelect: { + margin: 0, + width: 200, + "& .MuiSelect-root": { + paddingTop: 12, + paddingBottom: 12, + ".secondary": { + display: "none", + }, + }, + }, + role: { + textTransform: "capitalize", + }, + menuItem: { + lineHeight: "140%", + paddingTop: 12, + paddingBottom: 12, + whiteSpace: "normal", + inlineSize: "250px", + }, + menuItemSecondary: (theme) => ({ + fontSize: 14, + color: theme.palette.text.secondary, + }), +} satisfies Record>; From b22a209421073442d74e555a7fb8c0c1424dfd3c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 27 Nov 2025 17:06:21 +0000 Subject: [PATCH 2/9] fix: fix lint errors --- site/src/api/queries/workspaces.ts | 2 +- .../components/UserOrGroupAutocomplete/UserOrGroupOption.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index a8f7d2c656c6a..8042c929b12bb 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -45,7 +45,7 @@ export const workspaceByOwnerAndName = (owner: string, name: string) => { }; }; -export const workspaceACLKey = (workspaceId: string) => [ +const workspaceACLKey = (workspaceId: string) => [ "workspaceAcl", workspaceId, ]; diff --git a/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx b/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx index c4d70745fe1dd..7485cd01efa90 100644 --- a/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx +++ b/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx @@ -3,7 +3,7 @@ import { AvatarData } from "components/Avatar/AvatarData"; import type { HTMLAttributes } from "react"; import { getGroupSubtitle } from "utils/groups"; -export type UserOrGroupAutocompleteValue = User | ReducedUser | Group | null; +type UserOrGroupAutocompleteValue = User | ReducedUser | Group | null; type UserOption = User | ReducedUser; type OptionType = UserOption | Group; From a14c703af1c9ff324fd4c93c0ef595d3b802cb03 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 2 Dec 2025 17:30:56 +0000 Subject: [PATCH 3/9] fix: format --- site/src/api/queries/workspaces.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 8042c929b12bb..adcdeb2bbfbe9 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -45,10 +45,7 @@ export const workspaceByOwnerAndName = (owner: string, name: string) => { }; }; -const workspaceACLKey = (workspaceId: string) => [ - "workspaceAcl", - workspaceId, -]; +const workspaceACLKey = (workspaceId: string) => ["workspaceAcl", workspaceId]; export const workspaceACL = (workspaceId: string) => { return { From 24b8b1fa92dcc6d59e2550d0b68f015582f04c4c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 9 Dec 2025 16:17:53 +0000 Subject: [PATCH 4/9] chore: add error handling --- .../WorkspaceSharingPage.tsx | 156 +++++++++++++----- 1 file changed, 117 insertions(+), 39 deletions(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx index 7d69827efda77..840098f96d40c 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx @@ -1,3 +1,4 @@ +import { getErrorMessage } from "api/errors"; import { checkAuthorization } from "api/queries/authCheck"; import { setWorkspaceGroupRole, @@ -5,11 +6,20 @@ import { workspaceACL, } from "api/queries/workspaces"; import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { Link } from "components/Link/Link"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { CircleHelp } from "lucide-react"; import type { WorkspacePermissions } from "modules/workspaces/permissions"; import { workspaceChecks } from "modules/workspaces/permissions"; import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { docs } from "utils/docs"; import { pageTitle } from "utils/page"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; import { WorkspaceSharingPageView } from "./WorkspaceSharingPageView"; @@ -36,9 +46,36 @@ const WorkspaceSharingPage: FC = () => { const canUpdatePermissions = Boolean(permissions?.updateWorkspace); return ( - <> +
    {pageTitle(workspace.name, "Sharing")} +
    + + +

    Workspace sharing

    + + + + + + + Workspace sharing allows you to share workspaces with other + users and groups. +
    + + View docs + +
    +
    +
    +
    +
    +
    + {workspaceACLQuery.isError && ( )} @@ -51,21 +88,34 @@ const WorkspaceSharingPage: FC = () => { workspaceACL={workspaceACLQuery.data} canUpdatePermissions={canUpdatePermissions} onAddUser={async (user, role, reset) => { - await addUserMutation.mutateAsync({ - workspaceId: workspace.id, - userId: user.id, - role, - }); - reset(); + try { + await addUserMutation.mutateAsync({ + workspaceId: workspace.id, + userId: user.id, + role, + }); + displaySuccess("User added to workspace successfully!"); + reset(); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to add user to workspace"), + ); + } }} isAddingUser={addUserMutation.isPending} onUpdateUser={async (user, role) => { - await updateUserMutation.mutateAsync({ - workspaceId: workspace.id, - userId: user.id, - role, - }); - displaySuccess("User role updated successfully!"); + try { + await updateUserMutation.mutateAsync({ + workspaceId: workspace.id, + userId: user.id, + role, + }); + displaySuccess("User role updated successfully!"); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to update user role in workspace"), + ); + } }} updatingUserId={ updateUserMutation.isPending @@ -73,29 +123,51 @@ const WorkspaceSharingPage: FC = () => { : undefined } onRemoveUser={async (user) => { - await removeUserMutation.mutateAsync({ - workspaceId: workspace.id, - userId: user.id, - role: "", - }); - displaySuccess("User removed successfully!"); + try { + await removeUserMutation.mutateAsync({ + workspaceId: workspace.id, + userId: user.id, + role: "", + }); + displaySuccess("User removed successfully!"); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to remove user from workspace"), + ); + } }} onAddGroup={async (group, role, reset) => { - await addGroupMutation.mutateAsync({ - workspaceId: workspace.id, - groupId: group.id, - role, - }); - reset(); + try { + await addGroupMutation.mutateAsync({ + workspaceId: workspace.id, + groupId: group.id, + role, + }); + displaySuccess("Group added to workspace successfully!"); + reset(); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to add group to workspace"), + ); + } }} isAddingGroup={addGroupMutation.isPending} onUpdateGroup={async (group, role) => { - await updateGroupMutation.mutateAsync({ - workspaceId: workspace.id, - groupId: group.id, - role, - }); - displaySuccess("Group role updated successfully!"); + try { + await updateGroupMutation.mutateAsync({ + workspaceId: workspace.id, + groupId: group.id, + role, + }); + displaySuccess("Group role updated successfully!"); + } catch (error) { + displayError( + getErrorMessage( + error, + "Failed to update group role in workspace", + ), + ); + } }} updatingGroupId={ updateGroupMutation.isPending @@ -103,15 +175,21 @@ const WorkspaceSharingPage: FC = () => { : undefined } onRemoveGroup={async (group) => { - await removeGroupMutation.mutateAsync({ - workspaceId: workspace.id, - groupId: group.id, - role: "", - }); - displaySuccess("Group removed successfully!"); + try { + await removeGroupMutation.mutateAsync({ + workspaceId: workspace.id, + groupId: group.id, + role: "", + }); + displaySuccess("Group removed successfully!"); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to remove group from workspace"), + ); + } }} /> - +
    ); }; From e0076c09f787da848dbc862caa0f77f275cf1592 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 9 Dec 2025 20:50:39 +0000 Subject: [PATCH 5/9] chore: center checkmark in Select --- site/src/components/Select/Select.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/Select/Select.tsx b/site/src/components/Select/Select.tsx index f9261629e93c7..202b2a20fd190 100644 --- a/site/src/components/Select/Select.tsx +++ b/site/src/components/Select/Select.tsx @@ -141,7 +141,7 @@ export const SelectItem = React.forwardRef< )} {...props} > - + From 2e21cb467b62c610ec2f386ef0e8da45d58995ed Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 9 Dec 2025 20:50:57 +0000 Subject: [PATCH 6/9] fix: remove unnecessary nullish --- .../TemplatePermissionsPage/UserOrGroupAutocomplete.tsx | 2 +- .../WorkspaceSharingPage/UserOrGroupAutocomplete.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx index d656f845ab100..f1ea31d73a5d0 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx @@ -66,7 +66,7 @@ export const UserOrGroupAutocomplete: FC = ({ return ( noOptionsText="No users or groups found" - value={value ?? null} + value={value} id="user-or-group-autocomplete" open={autoComplete.open} onOpen={() => { diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx index dff2a3c61c010..6e1856a887e1d 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx @@ -85,7 +85,7 @@ export const UserOrGroupAutocomplete: FC = ({ return ( noOptionsText="No users or groups found" - value={value ?? null} + value={value} id="workspace-user-or-group-autocomplete" open={autoComplete.open} onOpen={() => { From f75c3d8aa8f0c3fa84d2c126e7e9b7e23dfebfa1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 9 Dec 2025 20:51:22 +0000 Subject: [PATCH 7/9] chore: update header --- .../WorkspaceSharingPage.tsx | 45 +++++-------------- 1 file changed, 10 insertions(+), 35 deletions(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx index 840098f96d40c..b5cda75d9cd6b 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx @@ -7,19 +7,10 @@ import { } from "api/queries/workspaces"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; -import { Link } from "components/Link/Link"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "components/Tooltip/Tooltip"; -import { CircleHelp } from "lucide-react"; import type { WorkspacePermissions } from "modules/workspaces/permissions"; import { workspaceChecks } from "modules/workspaces/permissions"; import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { docs } from "utils/docs"; import { pageTitle } from "utils/page"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; import { WorkspaceSharingPageView } from "./WorkspaceSharingPageView"; @@ -46,34 +37,18 @@ const WorkspaceSharingPage: FC = () => { const canUpdatePermissions = Boolean(permissions?.updateWorkspace); return ( -
    +
    {pageTitle(workspace.name, "Sharing")} -
    - - -

    Workspace sharing

    - - - - - - - Workspace sharing allows you to share workspaces with other - users and groups. -
    - - View docs - -
    -
    -
    -
    -
    +
    +
    +

    Workspace sharing

    +

    + Workspace sharing allows you to share workspaces with other users + and groups. + {/* TODO: ADD DOCS LINK HERE View docs */} +

    +
    {workspaceACLQuery.isError && ( From 1dfb69f42d7947f46294667ac583da779c7ec449 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 9 Dec 2025 20:52:05 +0000 Subject: [PATCH 8/9] chore: migrate away from mui select --- .../WorkspaceSharingPageView.tsx | 411 ++++++++---------- 1 file changed, 185 insertions(+), 226 deletions(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx index fd63ce510fc92..3086500f2a84a 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx @@ -1,6 +1,3 @@ -import type { Interpolation, Theme } from "@emotion/react"; -import MenuItem from "@mui/material/MenuItem"; -import Select, { type SelectProps } from "@mui/material/Select"; import type { Group, User, @@ -13,7 +10,6 @@ import type { import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; import { Button } from "components/Button/Button"; -import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; import { DropdownMenu, DropdownMenuContent, @@ -21,9 +17,14 @@ import { DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; import { EmptyState } from "components/EmptyState/EmptyState"; -import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/Select/Select"; import { Spinner } from "components/Spinner/Spinner"; -import { Stack } from "components/Stack/Stack"; import { Table, TableBody, @@ -87,7 +88,7 @@ const AddWorkspaceUserOrGroup: FC = ({ } }} > - +
    = ({ /> - +
    ); }; -const RoleSelect: FC = (props) => { +interface RoleSelectProps { + value: WorkspaceRole; + disabled?: boolean; + onValueChange: (value: WorkspaceRole) => void; +} + +const RoleSelect: FC = ({ + value, + disabled, + onValueChange, +}) => { + const roleLabels: Record = { + use: "Use", + admin: "Admin", + "": "", + }; + return ( - + + + + {roleLabels[value]} + + + + + +
    Use
    +
    Can read and access this workspace.
    -
    - - -
    -
    Admin
    -
    + + +
    Admin
    +
    Can manage workspace metadata, permissions, and settings.
    -
    - + + ); }; @@ -201,202 +216,146 @@ export const WorkspaceSharingPageView: FC = ({ ); return ( - <> - - Sharing - - - - {canUpdatePermissions && ( - - "members" in value - ? onAddGroup(value, role, resetAutocomplete) - : onAddUser(value, role, resetAutocomplete) - } - /> - )} - - +
    + {canUpdatePermissions && ( + + "members" in value + ? onAddGroup(value, role, resetAutocomplete) + : onAddUser(value, role, resetAutocomplete) + } + /> + )} +
    + + + Member + Role + + + + + {!workspaceACL ? ( + + ) : isEmpty ? ( - Member - Role - + + + - - - - - - - - - - + {workspaceACL.group.map((group) => ( + + + + } + title={group.display_name || group.name} + subtitle={getGroupSubtitle(group)} /> - - - - {workspaceACL?.group.map((group) => ( - - - - } - title={group.display_name || group.name} - subtitle={getGroupSubtitle(group)} + + {canUpdatePermissions ? ( + onUpdateGroup(group, value)} /> - - - - - { - onUpdateGroup( - group, - event.target.value as WorkspaceRole, - ); - }} - /> - - -
    {group.role}
    -
    -
    -
    + ) : ( +
    {group.role}
    + )} +
    - - {canUpdatePermissions && ( - - - - - - onRemoveGroup(group)} - > - Remove - - - - )} - -
    - ))} + + {canUpdatePermissions && ( + + + + + + onRemoveGroup(group)} + > + Remove + + + + )} + + + ))} - {workspaceACL?.users.map((user) => ( - - - ( + + + + + + {canUpdatePermissions ? ( + onUpdateUser(user, value)} /> - - - - - { - onUpdateUser( - user, - event.target.value as WorkspaceRole, - ); - }} - /> - - -
    {user.role}
    -
    -
    -
    + ) : ( +
    {user.role}
    + )} +
    - - {canUpdatePermissions && ( - - - - - - onRemoveUser(user)} - > - Remove - - - - )} - -
    - ))} -
    -
    -
    -
    -
    - + + {canUpdatePermissions && ( + + + + + + onRemoveUser(user)} + > + Remove + + + + )} + + + ))} + + )} + + +
    ); }; - -const styles = { - select: { - fontSize: 14, - width: 100, - }, - updateSelect: { - margin: 0, - width: 200, - "& .MuiSelect-root": { - paddingTop: 12, - paddingBottom: 12, - ".secondary": { - display: "none", - }, - }, - }, - role: { - textTransform: "capitalize", - }, - menuItem: { - lineHeight: "140%", - paddingTop: 12, - paddingBottom: 12, - whiteSpace: "normal", - inlineSize: "250px", - }, - menuItemSecondary: (theme) => ({ - fontSize: 14, - color: theme.palette.text.secondary, - }), -} satisfies Record>; From ca940833dac092c33aafbb96bfb2868b66db5965 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 10 Dec 2025 21:57:13 +0000 Subject: [PATCH 9/9] chore: cleanup --- .../UserOrGroupAutocomplete/UserOrGroupOption.tsx | 11 ----------- .../UserOrGroupAutocomplete.tsx | 11 +++++------ .../WorkspaceSharingPage/UserOrGroupAutocomplete.tsx | 11 +++++------ .../WorkspaceSharingPage/WorkspaceSharingPageView.tsx | 4 ---- 4 files changed, 10 insertions(+), 27 deletions(-) diff --git a/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx b/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx index 7485cd01efa90..dd9506fc7e837 100644 --- a/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx +++ b/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx @@ -23,10 +23,6 @@ interface UserOrGroupOptionProps { htmlProps: HTMLAttributes; } -/** - * Shared render component for user/group autocomplete options. - * Displays avatar, name, and subtitle for both users and groups. - */ export const UserOrGroupOption = ({ option, htmlProps, @@ -45,10 +41,3 @@ export const UserOrGroupOption = ({ ); }; - -/** - * Tailwind classes for the autocomplete container. - * Apply to the MUI Autocomplete component. - */ -export const autocompleteClassName = - "w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full"; diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx index f1ea31d73a5d0..23da3c2784981 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx @@ -4,7 +4,6 @@ import TextField from "@mui/material/TextField"; import { templaceACLAvailable } from "api/queries/templates"; import type { Group, ReducedUser } from "api/typesGenerated"; import { - autocompleteClassName, isGroup, UserOrGroupOption, } from "components/UserOrGroupAutocomplete/UserOrGroupOption"; @@ -64,7 +63,7 @@ export const UserOrGroupAutocomplete: FC = ({ ); return ( - + = ({ onChange(newValue); }} isOptionEqualToValue={(option, optionValue) => - optionValue !== null && option.id === optionValue.id + option.id === optionValue.id } getOptionLabel={(option) => isGroup(option) ? option.display_name || option.name : option.email } - renderOption={(props, option) => ( - + renderOption={({ key, ...props }, option) => ( + )} options={options} loading={aclAvailableQuery.isFetching} - className={autocompleteClassName} + className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full" renderInput={(params) => ( = ({ ); return ( - + = ({ onChange(newValue ?? null); }} isOptionEqualToValue={(option, optionValue) => - optionValue !== null && option.id === optionValue.id + option.id === optionValue.id } getOptionLabel={(option) => isGroup(option) ? option.display_name || option.name : option.email } - renderOption={(props, option) => ( - + renderOption={({ key, ...props }, option) => ( + )} options={options} loading={usersQuery.isFetching || groupsQuery.isFetching} - className={autocompleteClassName} + className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full" renderInput={(params) => ( void; updatingUserId: WorkspaceUser["id"] | undefined; onRemoveUser: (user: WorkspaceUser) => void; - // Group onAddGroup: (group: Group, role: WorkspaceRole, reset: () => void) => void; isAddingGroup: boolean; onUpdateGroup: (group: WorkspaceGroup, role: WorkspaceRole) => void; @@ -196,13 +194,11 @@ export const WorkspaceSharingPageView: FC = ({ workspace, workspaceACL, canUpdatePermissions, - // User onAddUser, isAddingUser, updatingUserId, onUpdateUser, onRemoveUser, - // Group onAddGroup, isAddingGroup, updatingGroupId,