feat: add workspace share button and dialog (#21299)

resolves coder/internal#1074

<img width="621" height="421" alt="Screenshot 2025-12-16 at 20 31 33"
src="https://github.com/user-attachments/assets/c886d261-1e98-444b-9346-5f30055772ae"
/>
<img width="648" height="483" alt="Screenshot 2025-12-16 at 20 31 16"
src="https://github.com/user-attachments/assets/c247cef2-38b0-4255-b41b-cc91de2cdf2b"
/>
This commit is contained in:
Jaayden Halko
2025-12-17 20:29:34 +00:00
committed by GitHub
parent f1b930b190
commit e4a06f842a
6 changed files with 302 additions and 200 deletions
@@ -0,0 +1,80 @@
import type {
Group,
User,
WorkspaceACL,
WorkspaceRole,
WorkspaceUser,
} from "api/typesGenerated";
import {
UserOrGroupAutocomplete,
type UserOrGroupAutocompleteValue,
} from "modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete";
import {
AddWorkspaceMemberForm,
RoleSelectField,
} from "modules/workspaces/WorkspaceSharingForm/WorkspaceSharingForm";
import { type FC, useState } from "react";
type AddWorkspaceUserOrGroupProps = {
organizationID: string;
isLoading: boolean;
workspaceACL: WorkspaceACL | undefined;
onSubmit: (
value: WorkspaceUser | Group | ({ role: WorkspaceRole } & User),
role: WorkspaceRole,
reset: () => void,
) => void;
};
export 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 (
<AddWorkspaceMemberForm
isLoading={isLoading}
disabled={!selectedRole || !selectedOption}
onSubmit={() => {
if (selectedOption && selectedRole) {
onSubmit(
{
...selectedOption,
role: selectedRole,
},
selectedRole,
resetValues,
);
}
}}
>
<UserOrGroupAutocomplete
organizationId={organizationID}
value={selectedOption}
exclude={excludeFromAutocomplete}
onChange={(newValue) => {
setSelectedOption(newValue);
}}
/>
<RoleSelectField
value={selectedRole}
onChange={setSelectedRole}
disabled={isLoading}
/>
</AddWorkspaceMemberForm>
);
};
@@ -148,6 +148,7 @@ interface WorkspaceSharingFormProps {
updatingGroupId?: WorkspaceGroup["id"] | undefined;
onRemoveGroup: (group: Group) => void;
addMemberForm?: ReactNode;
isCompact?: boolean;
}
export const WorkspaceSharingForm: FC<WorkspaceSharingFormProps> = ({
@@ -161,6 +162,7 @@ export const WorkspaceSharingForm: FC<WorkspaceSharingFormProps> = ({
onUpdateGroup,
onRemoveGroup,
addMemberForm,
isCompact,
}) => {
const isEmpty = Boolean(
workspaceACL &&
@@ -168,136 +170,160 @@ export const WorkspaceSharingForm: FC<WorkspaceSharingFormProps> = ({
workspaceACL.group.length === 0,
);
const tableHeader = (
<TableHeader>
<TableRow>
<TableHead className="w-[50%] py-2">Member</TableHead>
<TableHead className="w-[40%] py-2">Role</TableHead>
<TableHead className="w-[10%] py-2" />
</TableRow>
</TableHeader>
);
const tableBody = (
<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"
isCompact={isCompact}
/>
</TableCell>
</TableRow>
) : (
<>
{workspaceACL.group.map((group) => (
<TableRow key={group.id}>
<TableCell className="py-2 w-[50%]">
<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 w-[40%]">
{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 w-[10%]">
{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 w-[50%]">
<AvatarData
title={user.username}
subtitle={user.name}
src={user.avatar_url}
/>
</TableCell>
<TableCell className="py-2 w-[40%]">
{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 w-[10%]">
{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>
);
if (isCompact) {
return (
<div className="flex flex-col gap-4">
{Boolean(error) && <ErrorAlert error={error} />}
{canUpdatePermissions && addMemberForm}
<div>
<Table>{tableHeader}</Table>
<div className="max-h-60 overflow-y-auto">
<Table>{tableBody}</Table>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-4">
{Boolean(error) && <ErrorAlert error={error} />}
{canUpdatePermissions && addMemberForm}
<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>
{tableHeader}
{tableBody}
</Table>
</div>
);
@@ -0,0 +1,62 @@
import type { Workspace } from "api/typesGenerated";
import { TopbarButton } from "components/FullPageLayout/Topbar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import { UsersIcon } from "lucide-react";
import { isGroup } from "modules/groups";
import { AddWorkspaceUserOrGroup } from "modules/workspaces/WorkspaceSharingForm/AddWorkspaceUserOrGroup";
import { useWorkspaceSharing } from "modules/workspaces/WorkspaceSharingForm/useWorkspaceSharing";
import { WorkspaceSharingForm } from "modules/workspaces/WorkspaceSharingForm/WorkspaceSharingForm";
import type { FC } from "react";
interface ShareButtonProps {
workspace: Workspace;
canUpdatePermissions: boolean;
}
export const ShareButton: FC<ShareButtonProps> = ({
workspace,
canUpdatePermissions,
}) => {
const sharing = useWorkspaceSharing(workspace);
return (
<Popover>
<PopoverTrigger asChild>
<TopbarButton data-testid="workspace-share-button">
<UsersIcon />
Share
</TopbarButton>
</PopoverTrigger>
<PopoverContent align="end" className="w-[580px] p-4">
<WorkspaceSharingForm
workspaceACL={sharing.workspaceACL}
canUpdatePermissions={canUpdatePermissions}
error={sharing.error ?? sharing.mutationError}
updatingUserId={sharing.updatingUserId}
onUpdateUser={sharing.updateUser}
onRemoveUser={sharing.removeUser}
updatingGroupId={sharing.updatingGroupId}
onUpdateGroup={sharing.updateGroup}
onRemoveGroup={sharing.removeGroup}
isCompact
addMemberForm={
<AddWorkspaceUserOrGroup
organizationID={workspace.organization_id}
workspaceACL={sharing.workspaceACL}
isLoading={sharing.isAddingUser || sharing.isAddingGroup}
onSubmit={(value, role, resetAutocomplete) =>
isGroup(value)
? sharing.addGroup(value, role, resetAutocomplete)
: sharing.addUser(value, role, resetAutocomplete)
}
/>
}
/>
</PopoverContent>
</Popover>
);
};
@@ -22,6 +22,7 @@ import {
} from "./Buttons";
import { DebugButton } from "./DebugButton";
import { RetryButton } from "./RetryButton";
import { ShareButton } from "./ShareButton";
interface WorkspaceActionsProps {
workspace: Workspace;
@@ -188,6 +189,11 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
onToggle={handleToggleFavorite}
/>
<ShareButton
workspace={workspace}
canUpdatePermissions={permissions.updateWorkspace}
/>
<WorkspaceMoreActions workspace={workspace} disabled={!canAcceptJobs} />
</div>
);
@@ -1,6 +1,5 @@
import type {
Group,
User,
Workspace,
WorkspaceACL,
WorkspaceGroup,
@@ -8,80 +7,9 @@ import type {
WorkspaceUser,
} from "api/typesGenerated";
import { isGroup } from "modules/groups";
import {
AddWorkspaceMemberForm,
RoleSelectField,
WorkspaceSharingForm,
} from "modules/workspaces/WorkspaceSharingForm/WorkspaceSharingForm";
import { type FC, useState } from "react";
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 (
<AddWorkspaceMemberForm
isLoading={isLoading}
disabled={!selectedRole || !selectedOption}
onSubmit={() => {
if (selectedOption && selectedRole) {
onSubmit(
{
...selectedOption,
role: selectedRole,
},
selectedRole,
resetValues,
);
}
}}
>
<UserOrGroupAutocomplete
organizationId={organizationID}
value={selectedOption}
exclude={excludeFromAutocomplete}
onChange={(newValue) => {
setSelectedOption(newValue);
}}
/>
<RoleSelectField
value={selectedRole}
onChange={setSelectedRole}
disabled={isLoading}
/>
</AddWorkspaceMemberForm>
);
};
import { AddWorkspaceUserOrGroup } from "modules/workspaces/WorkspaceSharingForm/AddWorkspaceUserOrGroup";
import { WorkspaceSharingForm } from "modules/workspaces/WorkspaceSharingForm/WorkspaceSharingForm";
import type { FC } from "react";
interface WorkspaceSharingPageViewProps {
workspace: Workspace;