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:
Jaayden Halko
2025-12-12 21:07:35 +00:00
committed by GitHub
parent aba0e36964
commit b9f8295845
8 changed files with 771 additions and 66 deletions
+10
View File
@@ -1925,6 +1925,16 @@ class ApiMethods {
return response.data; 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 ( updateWorkspaceACL = async (
workspaceId: string, workspaceId: string,
data: TypesGen.UpdateWorkspaceACL, data: TypesGen.UpdateWorkspaceACL,
+60
View File
@@ -5,9 +5,11 @@ import type {
ProvisionerLogLevel, ProvisionerLogLevel,
UsageAppName, UsageAppName,
Workspace, Workspace,
WorkspaceACL,
WorkspaceAgentLog, WorkspaceAgentLog,
WorkspaceBuild, WorkspaceBuild,
WorkspaceBuildParameter, WorkspaceBuildParameter,
WorkspaceRole,
WorkspacesRequest, WorkspacesRequest,
WorkspacesResponse, WorkspacesResponse,
} from "api/typesGenerated"; } from "api/typesGenerated";
@@ -18,6 +20,7 @@ import {
} from "modules/workspaces/permissions"; } from "modules/workspaces/permissions";
import type { ConnectionStatus } from "pages/TerminalPage/types"; import type { ConnectionStatus } from "pages/TerminalPage/types";
import type { import type {
MutationOptions,
QueryClient, QueryClient,
QueryOptions, QueryOptions,
UseMutationOptions, 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 & { type CreateWorkspaceMutationVariables = CreateWorkspaceRequest & {
userId: string; userId: string;
}; };
+1 -1
View File
@@ -141,7 +141,7 @@ export const SelectItem = React.forwardRef<
)} )}
{...props} {...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"> <SelectPrimitive.ItemIndicator className="size-icon-sm">
<Check className="size-icon-sm" /> <Check className="size-icon-sm" />
</SelectPrimitive.ItemIndicator> </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>
);
};
@@ -1,17 +1,19 @@
import { css } from "@emotion/react";
import Autocomplete from "@mui/material/Autocomplete"; import Autocomplete from "@mui/material/Autocomplete";
import CircularProgress from "@mui/material/CircularProgress"; import CircularProgress from "@mui/material/CircularProgress";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { templaceACLAvailable } from "api/queries/templates"; import { templaceACLAvailable } from "api/queries/templates";
import type { Group, ReducedUser } from "api/typesGenerated"; 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 { useDebouncedFunction } from "hooks/debounce";
import { type ChangeEvent, type FC, useState } from "react"; import { type ChangeEvent, type FC, useState } from "react";
import { keepPreviousData, useQuery } from "react-query"; import { keepPreviousData, useQuery } from "react-query";
import { prepareQuery } from "utils/filters"; import { prepareQuery } from "utils/filters";
import { getGroupSubtitle } from "utils/groups";
export type UserOrGroupAutocompleteValue = ReducedUser | Group | null; export type UserOrGroupAutocompleteValue = ReducedUser | Group | null;
type AutocompleteOption = Exclude<UserOrGroupAutocompleteValue, null>;
type UserOrGroupAutocompleteProps = { type UserOrGroupAutocompleteProps = {
value: UserOrGroupAutocompleteValue; value: UserOrGroupAutocompleteValue;
@@ -38,7 +40,7 @@ export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
enabled: autoComplete.open, enabled: autoComplete.open,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
const options = aclAvailableQuery.data const options: AutocompleteOption[] = aclAvailableQuery.data
? [ ? [
...aclAvailableQuery.data.groups, ...aclAvailableQuery.data.groups,
...aclAvailableQuery.data.users, ...aclAvailableQuery.data.users,
@@ -81,68 +83,38 @@ export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
onChange={(_, newValue) => { onChange={(_, newValue) => {
onChange(newValue); onChange(newValue);
}} }}
isOptionEqualToValue={(option, value) => option.id === value.id} isOptionEqualToValue={(option, optionValue) =>
option.id === optionValue.id
}
getOptionLabel={(option) => getOptionLabel={(option) =>
isGroup(option) ? option.display_name || option.name : option.email isGroup(option) ? option.display_name || option.name : option.email
} }
renderOption={(props, option) => { renderOption={({ key, ...props }, option) => (
const isOptionGroup = isGroup(option); <UserOrGroupOption key={key} htmlProps={props} option={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>
);
}}
options={options} options={options}
loading={aclAvailableQuery.isFetching} loading={aclAvailableQuery.isFetching}
css={autoCompleteStyles} className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full"
renderInput={(params) => ( renderInput={(params) => (
<> <TextField
<TextField {...params}
{...params} margin="none"
margin="none" size="small"
size="small" placeholder="Search for user or group"
placeholder="Search for user or group" InputProps={{
InputProps={{ ...params.InputProps,
...params.InputProps, onChange: handleFilterChange,
onChange: handleFilterChange, endAdornment: (
endAdornment: ( <>
<> {aclAvailableQuery.isFetching ? (
{aclAvailableQuery.isFetching ? ( <CircularProgress size={16} />
<CircularProgress size={16} /> ) : null}
) : null} {params.InputProps.endAdornment}
{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%;
}
`;
@@ -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}
</>
),
}}
/>
)}
/>
);
};
@@ -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 type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { docs } from "utils/docs";
import { pageTitle } from "utils/page"; import { pageTitle } from "utils/page";
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
import { WorkspaceSharingPageView } from "./WorkspaceSharingPageView";
const WorkspaceSharingPage: FC = () => { const WorkspaceSharingPage: FC = () => {
const workspace = useWorkspaceSettings(); 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 ( return (
<> <div className="flex flex-col gap-12 max-w-screen-md">
<title>{pageTitle(workspace.name, "Sharing")}</title> <title>{pageTitle(workspace.name, "Sharing")}</title>
<PageHeader className="pt-0"> <header className="flex flex-col">
<PageHeaderTitle>Sharing</PageHeaderTitle> <div className="flex flex-col gap-2">
</PageHeader> <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>
); );
}; };
@@ -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>
);
};