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..adcdeb2bbfbe9 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,63 @@ export const workspaceByOwnerAndName = (owner: string, name: string) => { }; }; +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/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} > - + diff --git a/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx b/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx new file mode 100644 index 0000000000000..dd9506fc7e837 --- /dev/null +++ b/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx @@ -0,0 +1,43 @@ +import type { Group, ReducedUser, User } from "api/typesGenerated"; +import { AvatarData } from "components/Avatar/AvatarData"; +import type { HTMLAttributes } from "react"; +import { getGroupSubtitle } from "utils/groups"; + +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; +} + +export const UserOrGroupOption = ({ + option, + htmlProps, +}: UserOrGroupOptionProps) => { + const isOptionGroup = isGroup(option); + + return ( +
  • + +
  • + ); +}; diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx index 9a7624bf64ad9..23da3c2784981 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx @@ -1,17 +1,19 @@ -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 { + 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 +40,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, @@ -81,68 +83,38 @@ export const UserOrGroupAutocomplete: FC = ({ onChange={(_, newValue) => { onChange(newValue); }} - isOptionEqualToValue={(option, value) => option.id === value.id} + isOptionEqualToValue={(option, optionValue) => + option.id === optionValue.id + } getOptionLabel={(option) => isGroup(option) ? option.display_name || option.name : option.email } - renderOption={(props, option) => { - const isOptionGroup = isGroup(option); - - return ( -
  • - -
  • - ); - }} + renderOption={({ key, ...props }, option) => ( + + )} options={options} loading={aclAvailableQuery.isFetching} - css={autoCompleteStyles} + className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full" 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..8e61f92b2cd41 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx @@ -0,0 +1,141 @@ +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 { + 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 ( + { + 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) => + option.id === optionValue.id + } + getOptionLabel={(option) => + isGroup(option) ? option.display_name || option.name : option.email + } + renderOption={({ key, ...props }, option) => ( + + )} + options={options} + loading={usersQuery.isFetching || groupsQuery.isFetching} + className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full" + 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..b5cda75d9cd6b 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx @@ -1,19 +1,170 @@ -import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { getErrorMessage } from "api/errors"; +import { checkAuthorization } from "api/queries/authCheck"; +import { + setWorkspaceGroupRole, + setWorkspaceUserRole, + workspaceACL, +} from "api/queries/workspaces"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { displayError, 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 - - +
    +
    +

    Workspace sharing

    +

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

    +
    +
    + + {workspaceACLQuery.isError && ( + + )} + {permissionsQuery.isError && ( + + )} + + { + 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) => { + 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 + ? updateUserMutation.variables?.userId + : undefined + } + onRemoveUser={async (user) => { + 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) => { + 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) => { + 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 + ? updateGroupMutation.variables?.groupId + : undefined + } + onRemoveGroup={async (group) => { + 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"), + ); + } + }} + /> +
    ); }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx new file mode 100644 index 0000000000000..6a126a8d1158a --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx @@ -0,0 +1,357 @@ +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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/Select/Select"; +import { Spinner } from "components/Spinner/Spinner"; +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); + }} + /> + + + + +
    +
    + ); +}; + +interface RoleSelectProps { + value: WorkspaceRole; + disabled?: boolean; + onValueChange: (value: WorkspaceRole) => void; +} + +const RoleSelect: FC = ({ + value, + disabled, + onValueChange, +}) => { + const roleLabels: Record = { + use: "Use", + admin: "Admin", + "": "", + }; + + return ( + + ); +}; + +interface WorkspaceSharingPageViewProps { + workspace: Workspace; + workspaceACL: WorkspaceACL | undefined; + canUpdatePermissions: boolean; + 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; + 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, + onAddUser, + isAddingUser, + updatingUserId, + onUpdateUser, + onRemoveUser, + onAddGroup, + isAddingGroup, + updatingGroupId, + onUpdateGroup, + onRemoveGroup, +}) => { + const isEmpty = Boolean( + workspaceACL && + workspaceACL.users.length === 0 && + workspaceACL.group.length === 0, + ); + + return ( +
    + {canUpdatePermissions && ( + + "members" in value + ? onAddGroup(value, role, resetAutocomplete) + : onAddUser(value, role, resetAutocomplete) + } + /> + )} + + + + Member + Role + + + + + {!workspaceACL ? ( + + ) : isEmpty ? ( + + + + + + ) : ( + <> + {workspaceACL.group.map((group) => ( + + + + } + title={group.display_name || group.name} + subtitle={getGroupSubtitle(group)} + /> + + + {canUpdatePermissions ? ( + onUpdateGroup(group, value)} + /> + ) : ( +
    {group.role}
    + )} +
    + + + {canUpdatePermissions && ( + + + + + + onRemoveGroup(group)} + > + Remove + + + + )} + +
    + ))} + + {workspaceACL.users.map((user) => ( + + + + + + {canUpdatePermissions ? ( + onUpdateUser(user, value)} + /> + ) : ( +
    {user.role}
    + )} +
    + + + {canUpdatePermissions && ( + + + + + + onRemoveUser(user)} + > + Remove + + + + )} + +
    + ))} + + )} +
    +
    +
    + ); +};