diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 70b3c631ec..d1d7ba4795 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -742,6 +742,32 @@ class ApiMethods { return response.data; }; + /** + * @param organization Can be the organization's ID or name + */ + getWorkspaceSharingSettings = async ( + organization: string, + ): Promise => { + const response = await this.axios.get( + `/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 => { + const response = await this.axios.patch( + `/api/v2/organizations/${organization}/settings/workspace-sharing`, + data, + ); + return response.data; + }; + getProvisionerDaemonsByOrganization = async ( organization: string, params?: GetProvisionerDaemonsParams, diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 9f392a204b..86478c87e6 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -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 = {}, diff --git a/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx b/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx new file mode 100644 index 0000000000..f45e18544e --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx @@ -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 ( + !open && onCancel()}> + + + Disable workspace sharing + +
+

+ Disabling workspace sharing will{" "} + + immediately remove + {" "} + all existing workspace sharing permissions for all users in this + organization. +

+ {isLoadingCount ? ( + + ) : sharedCount > 0 ? ( +

+ This action will affect{" "} + + {sharedCount} workspace{sharedCount !== 1 ? "s" : ""} + {" "} + that {sharedCount !== 1 ? "are" : "is"} currently shared. +

+ ) : ( +

+ No workspaces are currently shared in this organization. +

+ )} +

+ Re-enabling workspace sharing will{" "} + not restore{" "} + these permissions. +

+
+
+
+ + + + +
+
+ ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx index edeff9287c..223e2d0825 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -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 ; } @@ -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} /> ); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx index fc3cf3767d..650b2d845b 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx @@ -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 = { @@ -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); + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx index a5891df618..aa0b538416 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx @@ -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; 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({ 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 (
@@ -129,21 +144,59 @@ export const OrganizationSettingsPageView: FC< + {onToggleWorkspaceSharing && ( + + +
+ { + if (checked) { + onToggleWorkspaceSharing(true); + } else { + setIsDisableSharingDialogOpen(true); + } + }} + /> +
+ +

+ When enabled, workspace owners can share their workspaces with + other users in this organization. +

+
+
+
+
+ )} + {!organization.is_default && ( -
- Deleting an organization is irreversible. - +
+
+ Deleting an organization is irreversible. + +
@@ -159,6 +212,17 @@ export const OrganizationSettingsPageView: FC< entity="organization" name={organization.name} /> + + { + await onToggleWorkspaceSharing?.(false); + setIsDisableSharingDialogOpen(false); + }} + onCancel={() => setIsDisableSharingDialogOpen(false)} + isLoading={isTogglingWorkspaceSharing} + />
); }; diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 79d1709894..198cca3a5c 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -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 = ({ latestVersion, permissions, timings, + sharingDisabled, handleStart, handleStop, handleRestart, @@ -110,6 +112,7 @@ export const Workspace: FC = ({ latestVersion={latestVersion} isUpdating={isUpdating} isRestarting={isRestarting} + sharingDisabled={sharingDisabled} handleStart={handleStart} handleStop={handleStop} handleRestart={handleRestart} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 6ebb8001b5..6af5b2230a 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -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 = ({ isUpdating, isRestarting, permissions, + sharingDisabled, handleToggleFavorite, handleStart, handleStop, @@ -189,10 +191,12 @@ export const WorkspaceActions: FC = ({ onToggle={handleToggleFavorite} /> - + {!sharingDisabled && ( + + )}
diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index ae3e5a017f..e5b6613103 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -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} /> ); }; diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 2291b5fb84..654c2e317a 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -34,12 +34,14 @@ interface WorkspaceReadyPageProps { template: TypesGen.Template; workspace: TypesGen.Workspace; permissions: WorkspacePermissions; + sharingDisabled?: boolean; } export const WorkspaceReadyPage: FC = ({ workspace, template, permissions, + sharingDisabled, }) => { const queryClient = useQueryClient(); @@ -283,6 +285,7 @@ export const WorkspaceReadyPage: FC = ({ template={template} buildLogs={buildLogs} timings={timingsQuery.data} + sharingDisabled={sharingDisabled} handleStart={async (buildParameters) => { const { hasEphemeral, ephemeralParameters } = await checkEphemeralParameters(buildParameters); diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index 36bfc26aa1..b5dfbde1aa 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -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 = ({ permissions, isUpdating, isRestarting, + sharingDisabled, handleStart, handleStop, handleRestart, @@ -236,6 +238,7 @@ export const WorkspaceTopbar: FC = ({ permissions={permissions} isUpdating={isUpdating} isRestarting={isRestarting} + sharingDisabled={sharingDisabled} handleStart={handleStart} handleStop={handleStop} handleRestart={handleRestart} diff --git a/site/src/pages/WorkspaceSettingsPage/Sidebar.tsx b/site/src/pages/WorkspaceSettingsPage/Sidebar.tsx index 32261577da..a69faba1c0 100644 --- a/site/src/pages/WorkspaceSettingsPage/Sidebar.tsx +++ b/site/src/pages/WorkspaceSettingsPage/Sidebar.tsx @@ -17,9 +17,14 @@ import type { FC } from "react"; interface SidebarProps { username: string; workspace: Workspace; + sharingDisabled?: boolean; } -export const Sidebar: FC = ({ username, workspace }) => { +export const Sidebar: FC = ({ + username, + workspace, + sharingDisabled, +}) => { const { experiments } = useDashboard(); return ( @@ -46,7 +51,7 @@ export const Sidebar: FC = ({ username, workspace }) => { Schedule - {experiments.includes("workspace-sharing") && ( + {experiments.includes("workspace-sharing") && !sharingDisabled && ( Sharing diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx index 57bf6a07df..e818a62fc5 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx @@ -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 ; } @@ -52,7 +59,11 @@ export const WorkspaceSettingsLayout: FC = () => { ) : ( workspace && ( - + }>