feat: add workspace sharing toggle on organization settings page (#21456)

resolves coder/internal#1211

<img width="1448" height="757" alt="Screenshot 2026-01-08 at 11 16 34"
src="https://github.com/user-attachments/assets/8d1e1b8b-e808-42a4-825a-f7f0f6fd8689"
/>

<img width="600" height="384" alt="Screenshot 2026-01-08 at 11 03 49"
src="https://github.com/user-attachments/assets/7fbe9b77-4617-4621-a566-972712210cbb"
/>

---------

Co-authored-by: George Katsitadze <george.katsitadze@gmail.com>
This commit is contained in:
Jaayden Halko
2026-01-15 17:18:23 +00:00
committed by GitHub
co-authored by George Katsitadze
parent 3a62a8e70e
commit d25d95231f
13 changed files with 339 additions and 20 deletions
+26
View File
@@ -742,6 +742,32 @@ class ApiMethods {
return response.data;
};
/**
* @param organization Can be the organization's ID or name
*/
getWorkspaceSharingSettings = async (
organization: string,
): Promise<TypesGen.WorkspaceSharingSettings> => {
const response = await this.axios.get<TypesGen.WorkspaceSharingSettings>(
`/api/v2/organizations/${organization}/settings/workspace-sharing`,
);
return response.data;
};
/**
* @param organization Can be the organization's ID or name
*/
patchWorkspaceSharingSettings = async (
organization: string,
data: TypesGen.WorkspaceSharingSettings,
): Promise<TypesGen.WorkspaceSharingSettings> => {
const response = await this.axios.patch<TypesGen.WorkspaceSharingSettings>(
`/api/v2/organizations/${organization}/settings/workspace-sharing`,
data,
);
return response.data;
};
getProvisionerDaemonsByOrganization = async (
organization: string,
params?: GetProvisionerDaemonsParams,
+27
View File
@@ -248,6 +248,33 @@ export const patchRoleSyncSettings = (
};
};
const getWorkspaceSharingSettingsKey = (organization: string) => [
"organization",
organization,
"workspaceSharingSettings",
];
export const workspaceSharingSettings = (organization: string) => {
return {
queryKey: getWorkspaceSharingSettingsKey(organization),
queryFn: () => API.getWorkspaceSharingSettings(organization),
};
};
export const patchWorkspaceSharingSettings = (
organization: string,
queryClient: QueryClient,
) => {
return {
mutationFn: (request: { sharing_disabled: boolean }) =>
API.patchWorkspaceSharingSettings(organization, request),
onSuccess: async () =>
await queryClient.invalidateQueries({
queryKey: getWorkspaceSharingSettingsKey(organization),
}),
};
};
export const provisionerJobsQueryKey = (
orgId: string,
params: GetProvisionerJobsParams = {},
@@ -0,0 +1,97 @@
import { API } from "api/api";
import { Button } from "components/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "components/Dialog/Dialog";
import { Skeleton } from "components/Skeleton/Skeleton";
import { Spinner } from "components/Spinner/Spinner";
import type { FC } from "react";
import { useQuery } from "react-query";
interface DisableWorkspaceSharingDialogProps {
isOpen: boolean;
organizationId: string;
onConfirm: () => void;
onCancel: () => void;
isLoading?: boolean;
}
export const DisableWorkspaceSharingDialog: FC<
DisableWorkspaceSharingDialogProps
> = ({ isOpen, organizationId, onConfirm, onCancel, isLoading }) => {
// Fetch the count of shared workspaces in this organization
const sharedWorkspacesQuery = useQuery({
queryKey: ["workspaces", organizationId, "shared", "count"],
queryFn: async () => {
const response = await API.getWorkspaces({
q: `organization:${organizationId} shared:true`,
limit: 0, // Avoid fetching workspaces as we only need the count
});
return response.count;
},
enabled: isOpen,
});
const sharedCount = sharedWorkspacesQuery.data ?? 0;
const isLoadingCount = sharedWorkspacesQuery.isLoading;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<DialogContent variant="destructive" className="max-w-xl">
<DialogHeader>
<DialogTitle>Disable workspace sharing</DialogTitle>
<DialogDescription asChild>
<div className="flex flex-col gap-4">
<p>
Disabling workspace sharing will{" "}
<strong className="text-content-primary">
immediately remove
</strong>{" "}
all existing workspace sharing permissions for all users in this
organization.
</p>
{isLoadingCount ? (
<Skeleton className="h-6 w-4/5" />
) : sharedCount > 0 ? (
<p className="text-content-danger font-medium m-0">
This action will affect{" "}
<strong className="text-content-primary">
{sharedCount} workspace{sharedCount !== 1 ? "s" : ""}
</strong>{" "}
that {sharedCount !== 1 ? "are" : "is"} currently shared.
</p>
) : (
<p className="text-content-secondary m-0">
No workspaces are currently shared in this organization.
</p>
)}
<p>
Re-enabling workspace sharing will{" "}
<strong className="text-content-primary">not restore</strong>{" "}
these permissions.
</p>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isLoading}
>
<Spinner loading={isLoading} />
Disable sharing
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,14 +1,16 @@
import { getErrorMessage } from "api/errors";
import {
deleteOrganization,
patchWorkspaceSharingSettings,
updateOrganization,
workspaceSharingSettings,
} from "api/queries/organizations";
import { EmptyState } from "components/EmptyState/EmptyState";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
import { RequirePermission } from "modules/permissions/RequirePermission";
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import { pageTitle } from "utils/page";
import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView";
@@ -25,6 +27,15 @@ const OrganizationSettingsPage: FC = () => {
deleteOrganization(queryClient),
);
const sharingSettingsQuery = useQuery({
...workspaceSharingSettings(organization?.id ?? ""),
enabled: !!organization,
});
const patchSharingSettingsMutation = useMutation(
patchWorkspaceSharingSettings(organization?.id ?? "", queryClient),
);
if (!organization) {
return <EmptyState message="Organization not found" />;
}
@@ -47,6 +58,26 @@ const OrganizationSettingsPage: FC = () => {
const error =
updateOrganizationMutation.error ?? deleteOrganizationMutation.error;
const handleToggleWorkspaceSharing = async (enabled: boolean) => {
try {
await patchSharingSettingsMutation.mutateAsync({
sharing_disabled: !enabled,
});
displaySuccess(
enabled ? "Workspace sharing enabled." : "Workspace sharing disabled.",
);
} catch (error) {
displayError(
getErrorMessage(
error,
enabled
? "Failed to enable workspace sharing"
: "Failed to disable workspace sharing",
),
);
}
};
return (
<>
{title}
@@ -73,6 +104,11 @@ const OrganizationSettingsPage: FC = () => {
);
}
}}
workspaceSharingEnabled={
!(sharingSettingsQuery.data?.sharing_disabled ?? false)
}
onToggleWorkspaceSharing={handleToggleWorkspaceSharing}
isTogglingWorkspaceSharing={patchSharingSettingsMutation.isPending}
/>
</>
);
@@ -4,6 +4,9 @@ import {
MockOrganization,
} from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { within } from "@testing-library/react";
import { action } from "storybook/actions";
import { userEvent } from "storybook/test";
import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView";
const meta: Meta<typeof OrganizationSettingsPageView> = {
@@ -25,3 +28,32 @@ export const DefaultOrg: Story = {
organization: MockDefaultOrganization,
},
};
export const WithWorkspaceSharingEnabled: Story = {
args: {
workspaceSharingEnabled: true,
onToggleWorkspaceSharing: action("onToggleWorkspaceSharing"),
},
};
export const WithWorkspaceSharingDisabled: Story = {
args: {
workspaceSharingEnabled: false,
onToggleWorkspaceSharing: action("onToggleWorkspaceSharing"),
},
};
export const DisableSharingDialog: Story = {
args: {
workspaceSharingEnabled: true,
onToggleWorkspaceSharing: action("onToggleWorkspaceSharing"),
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const body = within(canvasElement.ownerDocument.body);
const checkbox = await body.findByRole("checkbox", {
name: /allow workspace sharing/i,
});
await user.click(checkbox);
},
};
@@ -6,6 +6,7 @@ import type {
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { Checkbox } from "components/Checkbox/Checkbox";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import {
FormFields,
@@ -28,6 +29,7 @@ import {
onChangeTrimmed,
} from "utils/formUtils";
import * as Yup from "yup";
import { DisableWorkspaceSharingDialog } from "./DisableWorkspaceSharingDialog";
import { HorizontalContainer, HorizontalSection } from "./Horizontal";
const MAX_DESCRIPTION_CHAR_LIMIT = 128;
@@ -47,11 +49,22 @@ interface OrganizationSettingsPageViewProps {
error: unknown;
onSubmit: (values: UpdateOrganizationRequest) => Promise<void>;
onDeleteOrganization: () => void;
workspaceSharingEnabled?: boolean;
onToggleWorkspaceSharing?: (enabled: boolean) => void;
isTogglingWorkspaceSharing?: boolean;
}
export const OrganizationSettingsPageView: FC<
OrganizationSettingsPageViewProps
> = ({ organization, error, onSubmit, onDeleteOrganization }) => {
> = ({
organization,
error,
onSubmit,
onDeleteOrganization,
workspaceSharingEnabled = true,
onToggleWorkspaceSharing,
isTogglingWorkspaceSharing,
}) => {
const form = useFormik<UpdateOrganizationRequest>({
initialValues: {
name: organization.name,
@@ -66,6 +79,8 @@ export const OrganizationSettingsPageView: FC<
const getFieldHelpers = getFormHelpers(form, error);
const [isDeleting, setIsDeleting] = useState(false);
const [isDisableSharingDialogOpen, setIsDisableSharingDialogOpen] =
useState(false);
return (
<div className="w-full max-w-screen-2xl pb-10">
@@ -129,21 +144,59 @@ export const OrganizationSettingsPageView: FC<
</FormFooter>
</HorizontalForm>
{onToggleWorkspaceSharing && (
<HorizontalContainer className="mt-12">
<HorizontalSection
title="Workspace Sharing"
description="Control whether workspace owners can share their workspaces."
>
<div className="flex items-start gap-3">
<Checkbox
id="workspace-sharing"
checked={workspaceSharingEnabled}
disabled={isTogglingWorkspaceSharing}
onCheckedChange={(checked) => {
if (checked) {
onToggleWorkspaceSharing(true);
} else {
setIsDisableSharingDialogOpen(true);
}
}}
/>
<div className="flex flex-col">
<label
htmlFor="workspace-sharing"
className="text-sm font-medium cursor-pointer leading-none"
>
Allow workspace sharing
</label>
<p className="text-sm font-medium text-content-secondary mt-2">
When enabled, workspace owners can share their workspaces with
other users in this organization.
</p>
</div>
</div>
</HorizontalSection>
</HorizontalContainer>
)}
{!organization.is_default && (
<HorizontalContainer className="mt-12">
<HorizontalSection
title="Settings"
description="Change or delete your organization."
title="Delete Organization"
description="Delete your organization permanently."
>
<div className="flex bg-surface-orange items-center justify-between border border-solid border-orange-600 rounded-md p-3 pl-4 gap-2 flex-grow">
<span>Deleting an organization is irreversible.</span>
<Button
variant="destructive"
onClick={() => setIsDeleting(true)}
className="min-w-fit"
>
Delete this organization
</Button>
<div className="flex flex-col gap-4 flex-grow">
<div className="flex bg-surface-orange items-center justify-between border border-solid border-orange-600 rounded-md p-3 pl-4 gap-2">
<span>Deleting an organization is irreversible.</span>
<Button
variant="destructive"
onClick={() => setIsDeleting(true)}
className="min-w-fit"
>
Delete this organization
</Button>
</div>
</div>
</HorizontalSection>
</HorizontalContainer>
@@ -159,6 +212,17 @@ export const OrganizationSettingsPageView: FC<
entity="organization"
name={organization.name}
/>
<DisableWorkspaceSharingDialog
isOpen={isDisableSharingDialogOpen}
organizationId={organization.id}
onConfirm={async () => {
await onToggleWorkspaceSharing?.(false);
setIsDisableSharingDialogOpen(false);
}}
onCancel={() => setIsDisableSharingDialogOpen(false)}
isLoading={isTogglingWorkspaceSharing}
/>
</div>
);
};
@@ -33,6 +33,7 @@ interface WorkspaceProps {
buildLogs?: TypesGen.ProvisionerJobLog[];
latestVersion?: TypesGen.TemplateVersion;
timings?: TypesGen.WorkspaceBuildTimings;
sharingDisabled?: boolean;
handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
handleStop: () => void;
handleRestart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
@@ -56,6 +57,7 @@ export const Workspace: FC<WorkspaceProps> = ({
latestVersion,
permissions,
timings,
sharingDisabled,
handleStart,
handleStop,
handleRestart,
@@ -110,6 +112,7 @@ export const Workspace: FC<WorkspaceProps> = ({
latestVersion={latestVersion}
isUpdating={isUpdating}
isRestarting={isRestarting}
sharingDisabled={sharingDisabled}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
@@ -29,6 +29,7 @@ interface WorkspaceActionsProps {
isUpdating: boolean;
isRestarting: boolean;
permissions: WorkspacePermissions;
sharingDisabled?: boolean;
handleToggleFavorite: () => void;
handleStart: (buildParameters?: WorkspaceBuildParameter[]) => void;
handleStop: () => void;
@@ -45,6 +46,7 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
isUpdating,
isRestarting,
permissions,
sharingDisabled,
handleToggleFavorite,
handleStart,
handleStop,
@@ -189,10 +191,12 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
onToggle={handleToggleFavorite}
/>
<ShareButton
workspace={workspace}
canUpdatePermissions={permissions.updateWorkspace}
/>
{!sharingDisabled && (
<ShareButton
workspace={workspace}
canUpdatePermissions={permissions.updateWorkspace}
/>
)}
<WorkspaceMoreActions workspace={workspace} disabled={!canAcceptJobs} />
</div>
@@ -1,4 +1,5 @@
import { watchWorkspace } from "api/api";
import { workspaceSharingSettings } from "api/queries/organizations";
import { template as templateQueryOptions } from "api/queries/templates";
import { workspaceBuildsKey } from "api/queries/workspaceBuilds";
import {
@@ -44,6 +45,12 @@ const WorkspacePage: FC = () => {
const permissionsQuery = useQuery(workspacePermissions(workspace));
const permissions = permissionsQuery.data;
const sharingSettingsQuery = useQuery({
...workspaceSharingSettings(workspace?.organization_id ?? ""),
enabled: !!workspace,
});
const sharingDisabled = sharingSettingsQuery.data?.sharing_disabled ?? false;
// Watch workspace changes
const updateWorkspaceData = useEffectEvent(
async (newWorkspaceData: Workspace) => {
@@ -114,6 +121,7 @@ const WorkspacePage: FC = () => {
workspace={workspace}
template={template}
permissions={permissions}
sharingDisabled={sharingDisabled}
/>
);
};
@@ -34,12 +34,14 @@ interface WorkspaceReadyPageProps {
template: TypesGen.Template;
workspace: TypesGen.Workspace;
permissions: WorkspacePermissions;
sharingDisabled?: boolean;
}
export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
workspace,
template,
permissions,
sharingDisabled,
}) => {
const queryClient = useQueryClient();
@@ -283,6 +285,7 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
template={template}
buildLogs={buildLogs}
timings={timingsQuery.data}
sharingDisabled={sharingDisabled}
handleStart={async (buildParameters) => {
const { hasEphemeral, ephemeralParameters } =
await checkEphemeralParameters(buildParameters);
@@ -44,6 +44,7 @@ interface WorkspaceProps {
template: TypesGen.Template;
permissions: WorkspacePermissions;
latestVersion?: TypesGen.TemplateVersion;
sharingDisabled?: boolean;
handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
handleStop: () => void;
handleRestart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
@@ -62,6 +63,7 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
permissions,
isUpdating,
isRestarting,
sharingDisabled,
handleStart,
handleStop,
handleRestart,
@@ -236,6 +238,7 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
permissions={permissions}
isUpdating={isUpdating}
isRestarting={isRestarting}
sharingDisabled={sharingDisabled}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
@@ -17,9 +17,14 @@ import type { FC } from "react";
interface SidebarProps {
username: string;
workspace: Workspace;
sharingDisabled?: boolean;
}
export const Sidebar: FC<SidebarProps> = ({ username, workspace }) => {
export const Sidebar: FC<SidebarProps> = ({
username,
workspace,
sharingDisabled,
}) => {
const { experiments } = useDashboard();
return (
@@ -46,7 +51,7 @@ export const Sidebar: FC<SidebarProps> = ({ username, workspace }) => {
<SidebarNavItem href="schedule" icon={ScheduleIcon}>
Schedule
</SidebarNavItem>
{experiments.includes("workspace-sharing") && (
{experiments.includes("workspace-sharing") && !sharingDisabled && (
<SidebarNavItem href="sharing" icon={SharingIcon}>
Sharing
</SidebarNavItem>
@@ -1,3 +1,4 @@
import { workspaceSharingSettings } from "api/queries/organizations";
import { workspaceByOwnerAndName } from "api/queries/workspaces";
import type { Workspace } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
@@ -37,6 +38,12 @@ export const WorkspaceSettingsLayout: FC = () => {
isError,
} = useQuery(workspaceByOwnerAndName(username, workspaceName));
const sharingSettingsQuery = useQuery({
...workspaceSharingSettings(workspace?.organization_id ?? ""),
enabled: !!workspace,
});
const sharingDisabled = sharingSettingsQuery.data?.sharing_disabled ?? false;
if (isLoading) {
return <Loader />;
}
@@ -52,7 +59,11 @@ export const WorkspaceSettingsLayout: FC = () => {
) : (
workspace && (
<WorkspaceSettings.Provider value={workspace}>
<Sidebar workspace={workspace} username={username} />
<Sidebar
workspace={workspace}
username={username}
sharingDisabled={sharingDisabled}
/>
<Suspense fallback={<Loader />}>
<main css={{ width: "100%" }}>
<Outlet />