mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add workspace sharing page (#20931)
resolves coder/internal#849 <img width="870" height="554" alt="Screenshot 2025-12-10 at 20 37 38" src="https://github.com/user-attachments/assets/8712bf81-dc6f-4645-9e32-65eee7882e76" />
This commit is contained in:
@@ -1925,6 +1925,16 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getWorkspaceACL = async (
|
||||
workspaceId: string,
|
||||
): Promise<TypesGen.WorkspaceACL> => {
|
||||
const response = await this.axios.get(
|
||||
`/api/v2/workspaces/${workspaceId}/acl`,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
updateWorkspaceACL = async (
|
||||
workspaceId: string,
|
||||
data: TypesGen.UpdateWorkspaceACL,
|
||||
|
||||
@@ -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<WorkspaceACL>;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -141,7 +141,7 @@ export const SelectItem = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex items-center justify-center">
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator className="size-icon-sm">
|
||||
<Check className="size-icon-sm" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
|
||||
@@ -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<HTMLLIElement>;
|
||||
}
|
||||
|
||||
export const UserOrGroupOption = ({
|
||||
option,
|
||||
htmlProps,
|
||||
}: UserOrGroupOptionProps) => {
|
||||
const isOptionGroup = isGroup(option);
|
||||
|
||||
return (
|
||||
<li {...htmlProps}>
|
||||
<AvatarData
|
||||
title={
|
||||
isOptionGroup ? option.display_name || option.name : option.username
|
||||
}
|
||||
subtitle={isOptionGroup ? getGroupSubtitle(option) : option.email}
|
||||
src={option.avatar_url}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
+31
-59
@@ -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<UserOrGroupAutocompleteValue, null>;
|
||||
|
||||
type UserOrGroupAutocompleteProps = {
|
||||
value: UserOrGroupAutocompleteValue;
|
||||
@@ -38,7 +40,7 @@ export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
|
||||
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<UserOrGroupAutocompleteProps> = ({
|
||||
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 (
|
||||
<li {...props}>
|
||||
<AvatarData
|
||||
title={
|
||||
isOptionGroup
|
||||
? option.display_name || option.name
|
||||
: option.username
|
||||
}
|
||||
subtitle={isOptionGroup ? getGroupSubtitle(option) : option.email}
|
||||
src={option.avatar_url}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
renderOption={({ key, ...props }, option) => (
|
||||
<UserOrGroupOption key={key} htmlProps={props} option={option} />
|
||||
)}
|
||||
options={options}
|
||||
loading={aclAvailableQuery.isFetching}
|
||||
css={autoCompleteStyles}
|
||||
className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full"
|
||||
renderInput={(params) => (
|
||||
<>
|
||||
<TextField
|
||||
{...params}
|
||||
margin="none"
|
||||
size="small"
|
||||
placeholder="Search for user or group"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
onChange: handleFilterChange,
|
||||
endAdornment: (
|
||||
<>
|
||||
{aclAvailableQuery.isFetching ? (
|
||||
<CircularProgress size={16} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<TextField
|
||||
{...params}
|
||||
margin="none"
|
||||
size="small"
|
||||
placeholder="Search for user or group"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
onChange: handleFilterChange,
|
||||
endAdornment: (
|
||||
<>
|
||||
{aclAvailableQuery.isFetching ? (
|
||||
<CircularProgress size={16} />
|
||||
) : 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%;
|
||||
}
|
||||
`;
|
||||
|
||||
+141
@@ -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<UserOrGroupAutocompleteProps> = ({
|
||||
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<HTMLInputElement>) => {
|
||||
setAutoComplete((state) => ({
|
||||
...state,
|
||||
value: event.target.value,
|
||||
}));
|
||||
},
|
||||
500,
|
||||
);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
noOptionsText="No users or groups found"
|
||||
value={value}
|
||||
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) =>
|
||||
option.id === optionValue.id
|
||||
}
|
||||
getOptionLabel={(option) =>
|
||||
isGroup(option) ? option.display_name || option.name : option.email
|
||||
}
|
||||
renderOption={({ key, ...props }, option) => (
|
||||
<UserOrGroupOption key={key} htmlProps={props} option={option} />
|
||||
)}
|
||||
options={options}
|
||||
loading={usersQuery.isFetching || groupsQuery.isFetching}
|
||||
className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full"
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
margin="none"
|
||||
size="small"
|
||||
placeholder="Search for user or group"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
onChange: handleFilterChange,
|
||||
endAdornment: (
|
||||
<>
|
||||
{(usersQuery.isFetching || groupsQuery.isFetching) && (
|
||||
<CircularProgress size={16} />
|
||||
)}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+128
-6
@@ -1,19 +1,141 @@
|
||||
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 { Link } from "components/Link/Link";
|
||||
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";
|
||||
|
||||
const WorkspaceSharingPage: FC = () => {
|
||||
const workspace = useWorkspaceSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const workspaceACLQuery = useQuery(workspaceACL(workspace.id));
|
||||
const checks = workspaceChecks(workspace);
|
||||
const permissionsQuery = useQuery<WorkspacePermissions>({
|
||||
...checkAuthorization({ checks }),
|
||||
});
|
||||
const permissions = permissionsQuery.data;
|
||||
|
||||
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);
|
||||
|
||||
const mutationError =
|
||||
addUserMutation.error ??
|
||||
updateUserMutation.error ??
|
||||
removeUserMutation.error ??
|
||||
addGroupMutation.error ??
|
||||
updateGroupMutation.error ??
|
||||
removeGroupMutation.error;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-12 max-w-screen-md">
|
||||
<title>{pageTitle(workspace.name, "Sharing")}</title>
|
||||
|
||||
<PageHeader className="pt-0">
|
||||
<PageHeaderTitle>Sharing</PageHeaderTitle>
|
||||
</PageHeader>
|
||||
</>
|
||||
<header className="flex flex-col">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl m-0">Workspace sharing</h1>
|
||||
<p className="flex flex-row gap-1 text-sm text-content-secondary font-medium m-0">
|
||||
Workspace sharing allows you to share workspaces with other users
|
||||
and groups.{" "}
|
||||
<Link href={docs("/user-guides/shared-workspaces")}>View docs</Link>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{workspaceACLQuery.isError && (
|
||||
<ErrorAlert error={workspaceACLQuery.error} />
|
||||
)}
|
||||
{permissionsQuery.isError && (
|
||||
<ErrorAlert error={permissionsQuery.error} />
|
||||
)}
|
||||
{Boolean(mutationError) && <ErrorAlert error={mutationError} />}
|
||||
|
||||
<WorkspaceSharingPageView
|
||||
workspace={workspace}
|
||||
workspaceACL={workspaceACLQuery.data}
|
||||
canUpdatePermissions={canUpdatePermissions}
|
||||
onAddUser={async (user, role, reset) => {
|
||||
await addUserMutation.mutateAsync({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
role,
|
||||
});
|
||||
displaySuccess("User added to workspace successfully!");
|
||||
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,
|
||||
});
|
||||
displaySuccess("Group added to workspace successfully!");
|
||||
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!");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+357
@@ -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<AddWorkspaceUserOrGroupProps> = ({
|
||||
organizationID,
|
||||
isLoading,
|
||||
workspaceACL,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [selectedOption, setSelectedOption] =
|
||||
useState<UserOrGroupAutocompleteValue>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<WorkspaceRole>("use");
|
||||
const excludeFromAutocomplete = workspaceACL
|
||||
? [...workspaceACL.group, ...workspaceACL.users]
|
||||
: [];
|
||||
|
||||
const resetValues = () => {
|
||||
setSelectedOption(null);
|
||||
setSelectedRole("use");
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (selectedOption && selectedRole) {
|
||||
onSubmit(
|
||||
{
|
||||
...selectedOption,
|
||||
role: selectedRole,
|
||||
},
|
||||
selectedRole,
|
||||
resetValues,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<UserOrGroupAutocomplete
|
||||
organizationId={organizationID}
|
||||
value={selectedOption}
|
||||
exclude={excludeFromAutocomplete}
|
||||
onChange={(newValue) => {
|
||||
setSelectedOption(newValue);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={selectedRole}
|
||||
onValueChange={(value: WorkspaceRole) => setSelectedRole(value)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="use">Use</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
disabled={!selectedRole || !selectedOption || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
<Spinner loading={isLoading}>
|
||||
<UserPlusIcon className="size-icon-sm" />
|
||||
</Spinner>
|
||||
Add member
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface RoleSelectProps {
|
||||
value: WorkspaceRole;
|
||||
disabled?: boolean;
|
||||
onValueChange: (value: WorkspaceRole) => void;
|
||||
}
|
||||
|
||||
const RoleSelect: FC<RoleSelectProps> = ({
|
||||
value,
|
||||
disabled,
|
||||
onValueChange,
|
||||
}) => {
|
||||
const roleLabels: Record<WorkspaceRole, string> = {
|
||||
use: "Use",
|
||||
admin: "Admin",
|
||||
"": "",
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||
<SelectTrigger className="w-40 h-auto">
|
||||
<SelectValue>
|
||||
<span className="bg-surface-secondary rounded-md px-3 py-0.5 inline-block">
|
||||
{roleLabels[value]}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="use" className="flex-col items-start py-2 w-64">
|
||||
<div className="font-medium text-content-primary">Use</div>
|
||||
<div className="text-xs text-content-secondary leading-snug mt-0.5">
|
||||
Can read and access this workspace.
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="admin" className="flex-col items-start py-2 w-64">
|
||||
<div className="font-medium text-content-primary">Admin</div>
|
||||
<div className="text-xs text-content-secondary leading-snug mt-0.5">
|
||||
Can manage workspace metadata, permissions, and settings.
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
interface WorkspaceSharingPageViewProps {
|
||||
workspace: Workspace;
|
||||
workspaceACL: WorkspaceACL | undefined;
|
||||
canUpdatePermissions: boolean;
|
||||
onAddUser: (
|
||||
user: WorkspaceUser,
|
||||
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<WorkspaceSharingPageViewProps> = ({
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{canUpdatePermissions && (
|
||||
<AddWorkspaceUserOrGroup
|
||||
organizationID={workspace.organization_id}
|
||||
workspaceACL={workspaceACL}
|
||||
isLoading={isAddingUser || isAddingGroup}
|
||||
onSubmit={(value, role, resetAutocomplete) =>
|
||||
"members" in value
|
||||
? onAddGroup(value, role, resetAutocomplete)
|
||||
: onAddUser(value, role, resetAutocomplete)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60%] py-2">Member</TableHead>
|
||||
<TableHead className="w-[40%] py-2">Role</TableHead>
|
||||
<TableHead className="w-[1%] py-2" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!workspaceACL ? (
|
||||
<TableLoader />
|
||||
) : isEmpty ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No shared members or groups yet"
|
||||
description="Add a member or group using the controls above"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{workspaceACL.group.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="py-2">
|
||||
<AvatarData
|
||||
avatar={
|
||||
<Avatar
|
||||
size="lg"
|
||||
fallback={group.display_name || group.name}
|
||||
src={group.avatar_url}
|
||||
/>
|
||||
}
|
||||
title={group.display_name || group.name}
|
||||
subtitle={getGroupSubtitle(group)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
{canUpdatePermissions ? (
|
||||
<RoleSelect
|
||||
value={group.role}
|
||||
disabled={updatingGroupId === group.id}
|
||||
onValueChange={(value) => onUpdateGroup(group, value)}
|
||||
/>
|
||||
) : (
|
||||
<div className="capitalize">{group.role}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2">
|
||||
{canUpdatePermissions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="subtle"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<EllipsisVertical aria-hidden="true" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => onRemoveGroup(group)}
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{workspaceACL.users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="py-2">
|
||||
<AvatarData
|
||||
title={user.username}
|
||||
subtitle={user.name}
|
||||
src={user.avatar_url}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
{canUpdatePermissions ? (
|
||||
<RoleSelect
|
||||
value={user.role}
|
||||
disabled={updatingUserId === user.id}
|
||||
onValueChange={(value) => onUpdateUser(user, value)}
|
||||
/>
|
||||
) : (
|
||||
<div className="capitalize">{user.role}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2">
|
||||
{canUpdatePermissions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="subtle"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<EllipsisVertical aria-hidden="true" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => onRemoveUser(user)}
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user