diff --git a/site/permissions.json b/site/permissions.json index 66ef7aa237042..71b91a51b12c1 100644 --- a/site/permissions.json +++ b/site/permissions.json @@ -15,6 +15,14 @@ "object": { "resource_type": "template", "any_org": true }, "action": "create" }, + "createWorkspace": { + "object": { + "resource_type": "workspace", + "any_org": true, + "owner_id": "me" + }, + "action": "create" + }, "updateTemplates": { "object": { "resource_type": "template" }, "action": "update" diff --git a/site/site_test.go b/site/site_test.go index e8f693001e048..bb66ecaf9a641 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -275,6 +275,10 @@ func TestRenderPermissionsResolvesMe(t *testing.T) { err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithRole) require.NoError(t, err) assert.True(t, permsWithRole["createChat"], "user with agents-access role should have createChat = true") + // THEN: createWorkspace = true because the organization-member role + // grants creating a workspace owned by the member, and owner_id "me" + // resolves to the requesting user. + assert.True(t, permsWithRole["createWorkspace"], "org member should have createWorkspace = true") // GIVEN: a user without the agents-access role. userWithoutRole := dbgen.User(t, db, database.User{}) @@ -296,6 +300,36 @@ func TestRenderPermissionsResolvesMe(t *testing.T) { err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithoutRole) require.NoError(t, err) assert.False(t, permsWithoutRole["createChat"], "user without agents-access role should have createChat = false") + // THEN: createWorkspace = false because the user belongs to no + // organization, so the any_org check has no memberships to satisfy it. + assert.False(t, permsWithoutRole["createWorkspace"], "user without an org membership should have createWorkspace = false") + + // GIVEN: an org member whose only membership carries the + // workspace-creation ban role. + bannedUser := dbgen.User(t, db, database.User{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: org.ID, + UserID: bannedUser.ID, + Roles: []string{rbac.RoleOrgWorkspaceCreationBan()}, + }) + _, bannedToken := dbgen.APIKey(t, db, database.APIKey{ + UserID: bannedUser.ID, + ExpiresAt: time.Now().Add(time.Hour), + }) + + // WHEN: the user loads the page. + r = httptest.NewRequest("GET", "/", nil) + r.Header.Set(codersdk.SessionTokenHeader, bannedToken) + rw = httptest.NewRecorder() + handler.ServeHTTP(rw, r) + require.Equal(t, http.StatusOK, rw.Code) + + // THEN: createWorkspace = false because the ban's negative permission + // overrides the create permission granted by org membership. + var bannedPerms codersdk.AuthorizationResponse + err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &bannedPerms) + require.NoError(t, err) + assert.False(t, bannedPerms["createWorkspace"], "org member with a workspace-creation ban should have createWorkspace = false") } func TestInjectionFailureProducesCleanHTML(t *testing.T) { diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 87fb8947d2cb6..c004e152ab285 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Meta, StoryObj, WebSocketEvent } from "@storybook/react-vite"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { API } from "#/api/api"; @@ -13,35 +13,31 @@ import { import { withAuthProvider, withDashboardProvider, + withWebSocket, } from "#/testHelpers/storybook"; import CreateWorkspacePage from "./CreateWorkspacePage"; -/** - * Mocks API.templateVersionDynamicParameters to immediately send an empty - * DynamicParametersResponse so the page renders the form instead of the - * loader. - */ -function mockDynamicParameters() { - spyOn(API, "templateVersionDynamicParameters").mockImplementation( - (_versionId, _ownerId, callbacks) => { - // Fire asynchronously so the component mounts before the message - // arrives, matching real WebSocket behavior. - setTimeout(() => { - callbacks.onMessage({ id: 0, parameters: [], diagnostics: [] }); - }, 0); - - return { close: () => {} } as unknown as WebSocket; +// The page renders its form once the dynamic-parameters socket opens (which +// sends the initial parameters and records the response ID to wait for) and +// the server's initial id: -1 response arrives. +function dynamicParametersWebSocket(): WebSocketEvent[] { + return [ + { event: "open" }, + { + event: "message", + data: JSON.stringify({ id: -1, parameters: [], diagnostics: [] }), }, - ); + ]; } const meta: Meta = { title: "pages/CreateWorkspacePage", component: CreateWorkspacePage, - decorators: [withAuthProvider, withDashboardProvider], + decorators: [withAuthProvider, withDashboardProvider, withWebSocket], parameters: { layout: "fullscreen", user: MockUserOwner, + webSocket: dynamicParametersWebSocket(), reactRouter: reactRouterParameters({ location: { pathParams: { @@ -63,12 +59,13 @@ const meta: Meta = { spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion); spyOn(API, "getTemplateVersionPresets").mockResolvedValue(null); spyOn(API, "checkAuthorization").mockResolvedValue({ + createWorkspaceForUserID: true, createWorkspaceForAny: true, canUpdateTemplate: false, }); - // Dynamic parameters over WebSocket. - mockDynamicParameters(); + // Dynamic parameters over WebSocket are provided by the withWebSocket + // decorator and parameters.webSocket. // Default: no external auth required. spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); @@ -226,3 +223,61 @@ export const SequentialAuthFlow: Story = { }); }, }; + +/** + * A user without workspace-create permission is blocked by the + * RequirePermission dialog instead of seeing the form. + */ +export const PermissionDenied: Story = { + beforeEach: () => { + spyOn(API, "checkAuthorization").mockResolvedValue({ + createWorkspaceForUserID: false, + createWorkspaceForAny: false, + canUpdateTemplate: false, + }); + }, + play: async ({ canvasElement }) => { + // The dialog renders in a portal outside the story canvas. + const body = within(canvasElement.ownerDocument.body); + await body.findByText(/you don't have permission to view this page/i); + expect( + within(canvasElement).queryByRole("form", { + name: /create workspace/i, + }), + ).toBeNull(); + }, +}; + +/** + * A user without workspace-create permission following a ?mode=auto link is + * blocked by the RequirePermission dialog without seeing the auto-create + * consent dialog. + */ +export const PermissionDeniedAutoMode: Story = { + parameters: { + reactRouter: reactRouterParameters({ + location: { + pathParams: { + organization: MockTemplate.organization_name, + template: MockTemplate.name, + }, + searchParams: { mode: "auto" }, + }, + routing: { + path: "/templates/:organization/:template/workspace", + }, + }), + }, + beforeEach: () => { + spyOn(API, "checkAuthorization").mockResolvedValue({ + createWorkspaceForUserID: false, + createWorkspaceForAny: false, + canUpdateTemplate: false, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await body.findByText(/you don't have permission to view this page/i); + expect(body.queryByText(/automatic workspace creation/i)).toBeNull(); + }, +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index 7e28018cc23e3..e2474f3ab4ff2 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -7,7 +7,6 @@ import { MockDropdownParameter, MockDynamicParametersResponseWithError, MockMultiSelectParameter, - MockPermissions, MockPreviewParameter1, MockPreviewParameter2, MockPreviewParameter7, @@ -21,6 +20,7 @@ import { MockUserOwner, MockValidationParameter, MockWorkspace, + mockApiError, } from "#/testHelpers/entities"; import { checkParameters, editParameters } from "#/testHelpers/parameters"; import { @@ -55,6 +55,18 @@ describe("CreateWorkspacePage", () => { mockPublisher: MockWebSocketServer; }; + // checkAuthorization returns a boolean for each key it is asked about and no + // others, so the mock resolves every requested check from `overrides`, + // defaulting unlisted keys to false. + const mockCheckAuthorization = (overrides: Record = {}) => + vi + .spyOn(API, "checkAuthorization") + .mockImplementation(async ({ checks }) => + Object.fromEntries( + Object.keys(checks).map((key) => [key, overrides[key] ?? false]), + ), + ); + // Mocks the required endpoints, most importantly the web socket, constructs // the route with the required query parameters, then renders the page on that // route. @@ -222,7 +234,10 @@ describe("CreateWorkspacePage", () => { vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); vi.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([]); vi.spyOn(API, "createWorkspace").mockResolvedValue(MockWorkspace); - vi.spyOn(API, "checkAuthorization").mockResolvedValue(MockPermissions); + mockCheckAuthorization({ + createWorkspaceForUserID: true, + createWorkspaceForAny: true, + }); }); afterEach(() => { @@ -636,6 +651,87 @@ describe("CreateWorkspacePage", () => { }); }); + describe("Permissions", () => { + it("blocks the form behind a permission dialog when the user cannot create workspaces", async () => { + mockCheckAuthorization(); + + const { mockPublisher } = await renderPageWithSocket({}); + await expectSocketHandshake({ mockPublisher, parameters: [] }); + + expect( + await screen.findByText(/you don't have permission to view this page/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole("form", { name: /create workspace/i }), + ).not.toBeInTheDocument(); + }); + + it("blocks auto-creation without showing the consent dialog when the user cannot create workspaces", async () => { + mockCheckAuthorization(); + const autoCreateSpy = vi.spyOn(API, "createWorkspace"); + + const { mockPublisher } = await renderPageWithSocket({ + route: `/templates/${MockTemplate.name}/workspace?mode=auto`, + }); + await expectSocketHandshake({ mockPublisher, parameters: [] }); + + expect( + await screen.findByText(/you don't have permission to view this page/i), + ).toBeInTheDocument(); + expect( + screen.queryByText(/automatic workspace creation/i), + ).not.toBeInTheDocument(); + expect(autoCreateSpy).not.toHaveBeenCalled(); + }); + + it("shows an error instead of the form when the permission check fails", async () => { + // Only reject the page's own check batch; the auth provider also + // calls checkAuthorization and must keep resolving. + vi.spyOn(API, "checkAuthorization").mockImplementation( + async ({ checks }) => { + if ("createWorkspaceForUserID" in checks) { + throw mockApiError({ + message: "failed to check authorization", + }); + } + return {}; + }, + ); + + const { mockPublisher } = await renderPageWithSocket({}); + await expectSocketHandshake({ mockPublisher, parameters: [] }); + + expect( + await screen.findByRole("heading", { + name: /failed to check authorization/i, + }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("form", { name: /create workspace/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/you don't have permission to view this page/i), + ).not.toBeInTheDocument(); + }); + }); + + describe("Load Errors", () => { + it("shows an error instead of the loader when the template fails to load", async () => { + vi.spyOn(API, "getTemplateByName").mockRejectedValue( + mockApiError({ message: "failed to load template" }), + ); + + renderCreateWorkspacePage(); + + expect( + await screen.findByRole("heading", { + name: /failed to load template/i, + }), + ).toBeInTheDocument(); + expect(screen.queryByTestId("loader")).not.toBeInTheDocument(); + }); + }); + describe("Form Submission", () => { it("creates workspace with correct parameters", async () => { const parameters = [ diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 3636a262ced9e..d818a887f9db4 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -24,9 +24,12 @@ import type { MinimalUser, Workspace, } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Loader } from "#/components/Loader/Loader"; +import { Margins } from "#/components/Margins/Margins"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useExternalAuth } from "#/hooks/useExternalAuth"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { generateWorkspaceName } from "#/modules/workspaces/generateWorkspaceName"; import { pageTitle } from "#/utils/page"; import type { AutofillBuildParameter } from "#/utils/richParameters"; @@ -89,11 +92,18 @@ const CreateWorkspacePage: FC = () => { ...checkAuthorization({ checks: createWorkspaceChecks( templateQuery.data?.organization_id ?? "", + me.id, templateQuery.data?.id, ), }), enabled: Boolean(templateQuery.data), }); + // Scoped to the template's organization and to workspaces owned by the + // current user; holding workspace-create permission in other organizations + // does not grant access here. + const canCreateWorkspaceInOrg = Boolean( + permissionsQuery.data?.createWorkspaceForUserID, + ); const templateVersionQuery = useQuery({ ...templateVersion(realizedVersionId ?? ""), @@ -259,7 +269,10 @@ const CreateWorkspacePage: FC = () => { const isLoadingFormData = ws.current?.readyState === WebSocket.CONNECTING || templateQuery.isLoading || - permissionsQuery.isLoading; + // isPending stays true until the permission data exists, covering the + // renders where the query is still disabled or has not started fetching, + // during which isLoading would be false. + permissionsQuery.isPending; const loadFormDataError = templateQuery.error ?? permissionsQuery.error; const title = autoCreateWorkspaceMutation.isPending @@ -309,12 +322,14 @@ const CreateWorkspacePage: FC = () => { let autoCreateReady = mode === "auto" && + canCreateWorkspaceInOrg && hasAllRequiredExternalAuth && autoCreateConsented && presetResolved; const showAutoCreateConsent = mode === "auto" && + canCreateWorkspaceInOrg && !autoCreateConsented && !autoCreateError && presetResolved; @@ -403,66 +418,74 @@ const CreateWorkspacePage: FC = () => { onDeny={() => setMode("form")} /> - {shouldShowLoader ? ( + {loadFormDataError ? ( + // The view reads the template and permission results + // unconditionally, so render query failures as a page-level + // error instead of the form. + + + + ) : shouldShowLoader ? ( ) : ( - { - navigate(-1); - }} - onSubmit={async (request, owner) => { - let workspaceRequest = request; - if (realizedVersionId) { - workspaceRequest = { - ...request, - template_id: undefined, - template_version_id: realizedVersionId, - }; + + + resetMutation={createWorkspaceMutation.reset} + template={templateQuery.data} + versionId={realizedVersionId} + versionName={templateVersionQuery.data?.name} + externalAuth={externalAuth ?? []} + externalAuthPollingState={externalAuthPollingState} + startPollingExternalAuth={startPollingExternalAuth} + hasAllRequiredExternalAuth={hasAllRequiredExternalAuth} + permissions={permissionsQuery.data as CreateWorkspacePermissions} + parameters={sortedParams} + presets={presets} + urlPreset={urlPresetResult.preset} + urlPresetError={ + autoCreateError?.detail === urlPresetResult.error + ? undefined + : urlPresetResult.error + } + hasIgnoredUrlParams={hasIgnoredUrlParams} + creatingWorkspace={createWorkspaceMutation.isPending} + sendMessage={sendMessage} + onCancel={() => { + navigate(-1); + }} + onSubmit={async (request, owner) => { + let workspaceRequest = request; + if (realizedVersionId) { + workspaceRequest = { + ...request, + template_id: undefined, + template_version_id: realizedVersionId, + }; + } + + const workspace = await createWorkspaceMutation.mutateAsync({ + ...workspaceRequest, + userId: owner.id, + }); + onCreateWorkspace(workspace); + }} + /> + )} ); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index fb4b889ce7d8a..b516ebf33d583 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -27,6 +27,7 @@ const meta: Meta = { mode: "form", parameters: [], permissions: { + createWorkspaceForUserID: true, createWorkspaceForAny: true, canUpdateTemplate: false, }, diff --git a/site/src/pages/CreateWorkspacePage/permissions.ts b/site/src/pages/CreateWorkspacePage/permissions.ts index a5ca3a469f623..e4dea942d70a1 100644 --- a/site/src/pages/CreateWorkspacePage/permissions.ts +++ b/site/src/pages/CreateWorkspacePage/permissions.ts @@ -1,8 +1,17 @@ export const createWorkspaceChecks = ( organizationId: string, + userId: string, templateId?: string, ) => ({ + createWorkspaceForUserID: { + object: { + resource_type: "workspace" as const, + organization_id: organizationId, + owner_id: userId, + }, + action: "create" as const, + }, createWorkspaceForAny: { object: { resource_type: "workspace" as const, diff --git a/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx b/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx index 17baf47c5e770..ef15d1a653f52 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx @@ -10,12 +10,14 @@ interface WorkspacesEmptyProps { isUsingFilter: boolean; templates?: Template[]; canCreateTemplate: boolean; + canCreateWorkspace: boolean; } export const WorkspacesEmpty: FC = ({ isUsingFilter, templates, canCreateTemplate, + canCreateWorkspace, }) => { const getLink = useLinks(); @@ -34,6 +36,17 @@ export const WorkspacesEmpty: FC = ({ return ; } + if (!canCreateWorkspace) { + return ( + + ); + } + if (templates && templates.length === 0 && canCreateTemplate) { return ( { = { checkedWorkspaces: [], templates: mockTemplates, templatesFetchStatus: "success", + canCreateWorkspace: true, count: 13, page: 1, }, @@ -190,6 +191,55 @@ const meta: Meta = { export default meta; type Story = StoryObj; +export const CannotCreateWorkspace: Story = { + args: { + workspaces: [], + count: 0, + canCreateWorkspace: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.queryByRole("button", { name: /new workspace/i })).toBeNull(); + await canvas.findByText(/don't have permission to create workspaces/i); + }, +}; + +export const CannotCreateWorkspaceWithWorkspaces: Story = { + args: { + workspaces: allWorkspaces, + count: allWorkspaces.length, + canCreateWorkspace: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText(allWorkspaces[0].name); + expect(canvas.queryByRole("button", { name: /new workspace/i })).toBeNull(); + }, +}; + +export const CannotCreateWorkspaceWithFilter: Story = { + args: { + workspaces: [], + count: 0, + canCreateWorkspace: false, + filterState: { + ...defaultFilterProps, + filter: { ...defaultFilterProps.filter, used: true }, + }, + }, + // The filter empty state takes priority: an active filter that matched + // nothing shows "no results" regardless of create permission, since the + // user may own workspaces the filter excluded. + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText(/no results matched your search/i); + expect( + canvas.queryByText(/don't have permission to create workspaces/i), + ).toBeNull(); + expect(canvas.queryByRole("button", { name: /new workspace/i })).toBeNull(); + }, +}; + export const AllStates: Story = { args: { workspaces: allWorkspaces, @@ -247,6 +297,7 @@ export const OwnerHasNoWorkspaces: Story = { workspaces: [], count: 0, canCreateTemplate: true, + canCreateWorkspace: true, }, }; @@ -256,6 +307,7 @@ export const OwnerHasNoWorkspacesAndNoTemplates: Story = { templates: [], count: 0, canCreateTemplate: true, + canCreateWorkspace: true, }, }; @@ -264,6 +316,7 @@ export const UserHasNoWorkspaces: Story = { workspaces: [], count: 0, canCreateTemplate: false, + canCreateWorkspace: true, }, }; @@ -273,6 +326,7 @@ export const UserHasNoWorkspacesAndNoTemplates: Story = { templates: [], count: 0, canCreateTemplate: false, + canCreateWorkspace: true, }, }; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx new file mode 100644 index 0000000000000..c87395a888875 --- /dev/null +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx @@ -0,0 +1,111 @@ +import { screen } from "@testing-library/react"; +import { MockTemplate } from "#/testHelpers/entities"; +import { renderWithAuth } from "#/testHelpers/renderHelpers"; +import type { WorkspaceFilterState } from "./filter/WorkspacesFilter"; +import { WorkspacesPageView } from "./WorkspacesPageView"; + +const mockMenu = { + initialOption: undefined, + isInitializing: false, + isSearching: false, + query: "", + searchOptions: [], + selectedOption: undefined, + selectOption: vi.fn(), + setQuery: vi.fn(), +}; + +const createFilterState = (used = false) => + ({ + filter: { + query: "", + values: {}, + used, + update: vi.fn(), + debounceUpdate: vi.fn(), + cancelDebounce: vi.fn(), + }, + menus: { + user: mockMenu, + template: mockMenu, + status: mockMenu, + organizations: mockMenu, + }, + }) as WorkspaceFilterState; + +const defaultProps = { + error: undefined, + workspaces: [], + checkedWorkspaces: [], + count: 0, + filterState: createFilterState(), + page: 1, + limit: 25, + onPageChange: vi.fn(), + onCheckChange: vi.fn(), + isRunningBatchAction: false, + onBatchDeleteTransition: vi.fn(), + onBatchUpdateTransition: vi.fn(), + onBatchStartTransition: vi.fn(), + onBatchStopTransition: vi.fn(), + templatesFetchStatus: "success" as const, + templates: [MockTemplate], + canCreateTemplate: false, + canCreateWorkspace: true, + canChangeVersions: false, + onActionSuccess: vi.fn().mockResolvedValue(undefined), + onActionError: vi.fn(), +}; + +describe("WorkspacesPageView", () => { + it("hides the New workspace button and explains the missing permission", async () => { + renderWithAuth( + , + ); + + await screen.findByText(/don't have permission to create workspaces/i); + expect( + screen.queryByRole("button", { name: /new workspace/i }), + ).not.toBeInTheDocument(); + }); + + it("shows the New workspace button when the user can create workspaces", async () => { + renderWithAuth(); + + expect( + await screen.findByRole("button", { name: /new workspace/i }), + ).toBeInTheDocument(); + }); + + it("shows the filter empty state instead of the no-permission empty state when a filter is active", async () => { + renderWithAuth( + , + ); + + await screen.findByText(/no results matched your search/i); + expect( + screen.queryByText(/don't have permission to create workspaces/i), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /new workspace/i }), + ).not.toBeInTheDocument(); + }); + + it("shows the filter empty state when the user can create workspaces but the filter matches nothing", async () => { + renderWithAuth( + , + ); + + await screen.findByText(/no results matched your search/i); + expect( + screen.queryByText(/don't have permission to create workspaces/i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 6f0fb34ca674a..aa5c4da077e31 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -51,6 +51,7 @@ interface WorkspacesPageViewProps { templatesFetchStatus: TemplateQuery["status"]; templates: TemplateQuery["data"]; canCreateTemplate: boolean; + canCreateWorkspace: boolean; canChangeVersions: boolean; onActionSuccess: () => Promise; onActionError: (error: unknown) => void; @@ -75,6 +76,7 @@ export const WorkspacesPageView: FC = ({ templates, templatesFetchStatus, canCreateTemplate, + canCreateWorkspace, canChangeVersions, onActionSuccess, onActionError, @@ -89,12 +91,14 @@ export const WorkspacesPageView: FC = ({ - New workspace - + canCreateWorkspace && ( + + New workspace + + ) } > @@ -212,6 +216,7 @@ export const WorkspacesPageView: FC = ({ ) : ( void; templates?: Template[]; canCreateTemplate: boolean; + canCreateWorkspace: boolean; onActionSuccess: () => Promise; onActionError: (error: unknown) => void; chatsByWorkspace?: Record; @@ -106,6 +107,7 @@ export const WorkspacesTable: FC = ({ onCheckChange, templates, canCreateTemplate, + canCreateWorkspace, onActionSuccess, onActionError, chatsByWorkspace, @@ -168,6 +170,7 @@ export const WorkspacesTable: FC = ({ templates={templates} isUsingFilter={isUsingFilter} canCreateTemplate={canCreateTemplate} + canCreateWorkspace={canCreateWorkspace} /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 95158700332be..cc58d9b8f7770 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3297,6 +3297,7 @@ export const MockTemplateExample2: TypesGen.TemplateExample = { export const MockPermissions: Permissions = { createTemplates: true, createUser: true, + createWorkspace: true, deleteTemplates: true, updateTemplates: true, viewAllUsers: true, @@ -3333,6 +3334,7 @@ export const MockPermissions: Permissions = { export const MockNoPermissions: Permissions = { createTemplates: false, createUser: false, + createWorkspace: false, deleteTemplates: false, updateTemplates: false, viewAllUsers: false,