From ce769680ba3d87b2d10c1d152b04fa949f28d6c1 Mon Sep 17 00:00:00 2001 From: "J. Scott Miller" Date: Tue, 28 Jul 2026 18:09:54 -0500 Subject: [PATCH] feat(site): hide workspace resources when lacking workspace-create permission (#27278) Context: experiment `minimum-implicit-member ` added the ability to set the default member-roles at a per-organization level. This, along with the related PR stacked listed below, will be used to enable "gateway accounts", which are accounts that are entitled to use the AI Gateway but not create or use workspaces. The Workspaces tab is intentionally left visible for now. Hides the "New workspace" button and the empty-state creation CTA on the Workspaces page for users who cannot create a workspace in any organization, and guards the creation page itself. Adds a shared `createWorkspace` authorization check (`workspace` resource, `create` action, `owner_id: me`, `any_org: true`) to `site/permissions.json` and threads the result through `WorkspacesPageView`, `WorkspacesTable`, and `WorkspacesEmpty`. Users without the permission see an empty state explaining they don't have permission to create workspaces instead of a dead-end CTA. The create CTAs on the Templates pages were already gated by per-organization checks; this brings the Workspaces page in line. `CreateWorkspacePage` is also gated: it adds an org-scoped `createWorkspaceForUserID` check to its existing authorization batch and wraps the view in `RequirePermission`, so a direct URL shows the standard denial dialog instead of a form that 403s on submit. Users who can create workspaces for others (`createWorkspaceForAny`) still see the form. To see this behavior, enable the experiment. As an admin, visit Organization -> Roles, and remove "Organization Workspace Access" from the default roles. Login as a user that is not granted workspace access via a member role. Storybook coverage: `CannotCreateWorkspace` (empty state + hidden button), `CannotCreateWorkspaceWithWorkspaces` (button hidden while the table renders), `CannotCreateWorkspaceWithFilter` (pins the filter empty state's priority over the no-permission one), and `PermissionDenied` for the CreateWorkspacePage gate. The Go SSR permissions test also asserts the new `createWorkspace` entry. ## Stack This PR is independent but related to the gateway-accounts stack: 1. **#27279**: permission-based license seat counting. Behind the `permission-based-licensing` experiment and gated on the AI Governance add-on, `user_limit` counts only users the RBAC engine authorizes to create workspaces. 2. **#27280**: adds the `organization-ai-gateway-access` org role carrying the AI Bridge interception permissions (extracted from the member floors, backfilled into org default roles by migration) and enforces it at AI Gateway authentication; bridge usage stops claiming AI Governance seats under the experiment. 3. ~~**#27281**: gates workspace ACL grants on matching member-level capability (each granted action only takes effect while the recipient holds that action in the org), so workspace sharing is ineffective for (and rejected toward) users without workspace capabilities, evaluated live on every authorization.~~ Tabled - excluded from the gateway-accounts MVP. This PR (#27278) stands alone: it hides the Workspaces page create CTAs for users without workspace-create permission and can merge in any order. --- site/permissions.json | 8 + site/site_test.go | 34 +++++ .../CreateWorkspacePage.stories.tsx | 95 +++++++++--- .../CreateWorkspacePage.test.tsx | 100 ++++++++++++- .../CreateWorkspacePage.tsx | 137 ++++++++++-------- .../CreateWorkspacePageView.stories.tsx | 1 + .../pages/CreateWorkspacePage/permissions.ts | 9 ++ .../pages/WorkspacesPage/WorkspacesEmpty.tsx | 13 ++ .../WorkspacesPage/WorkspacesPage.stories.tsx | 1 + .../pages/WorkspacesPage/WorkspacesPage.tsx | 1 + .../WorkspacesPageView.stories.tsx | 54 +++++++ .../WorkspacesPageView.test.tsx | 111 ++++++++++++++ .../WorkspacesPage/WorkspacesPageView.tsx | 17 ++- .../pages/WorkspacesPage/WorkspacesTable.tsx | 3 + site/src/testHelpers/entities.ts | 2 + 15 files changed, 501 insertions(+), 85 deletions(-) create mode 100644 site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx diff --git a/site/permissions.json b/site/permissions.json index 66ef7aa237..71b91a51b1 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 e8f693001e..bb66ecaf9a 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 87fb8947d2..c004e152ab 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 7e28018cc2..e2474f3ab4 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 3636a262ce..d818a887f9 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, - }; + + { + 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); - }} - /> + 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 fb4b889ce7..b516ebf33d 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 a5ca3a469f..e4dea942d7 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 17baf47c5e..ef15d1a653 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 0000000000..c87395a888 --- /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 6f0fb34ca6..aa5c4da077 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 17a296a812..38cf409ccd 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3320,6 +3320,7 @@ export const MockTemplateExample2: TypesGen.TemplateExample = { export const MockPermissions: Permissions = { createTemplates: true, createUser: true, + createWorkspace: true, deleteTemplates: true, updateTemplates: true, viewAllUsers: true, @@ -3356,6 +3357,7 @@ export const MockPermissions: Permissions = { export const MockNoPermissions: Permissions = { createTemplates: false, createUser: false, + createWorkspace: false, deleteTemplates: false, updateTemplates: false, viewAllUsers: false,