mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
+3
-75
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user