From 92a858df149e25e70eb7e96fde52a39d3fac02bc Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 8 Jul 2026 21:15:49 +0000 Subject: [PATCH 01/20] feat(site/src/pages/WorkspacesPage): hide New workspace button without workspace-create permission Adds a createWorkspaceInAnyOrganization site-wide permission check (workspace create, any organization, owned by self) to the shared permissions.json and hides the New workspace button when it is false. The empty state drops its create CTAs for the same users and explains that workspace access requires administrator-granted permission. Gateway accounts, which lack organization-workspace-access, no longer see a create path that would only fail authorization. --- site/permissions.json | 8 ++++++++ .../pages/WorkspacesPage/WorkspacesEmpty.tsx | 13 +++++++++++++ .../src/pages/WorkspacesPage/WorkspacesPage.tsx | 1 + .../WorkspacesPageView.stories.tsx | 15 +++++++++++++++ .../pages/WorkspacesPage/WorkspacesPageView.tsx | 17 +++++++++++------ .../pages/WorkspacesPage/WorkspacesTable.tsx | 3 +++ site/src/testHelpers/entities.ts | 2 ++ 7 files changed, 53 insertions(+), 6 deletions(-) diff --git a/site/permissions.json b/site/permissions.json index 66ef7aa2370..1363688e489 100644 --- a/site/permissions.json +++ b/site/permissions.json @@ -15,6 +15,14 @@ "object": { "resource_type": "template", "any_org": true }, "action": "create" }, + "createWorkspaceInAnyOrganization": { + "object": { + "resource_type": "workspace", + "any_org": true, + "owner_id": "me" + }, + "action": "create" + }, "updateTemplates": { "object": { "resource_type": "template" }, "action": "update" diff --git a/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx b/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx index 17baf47c5e7..ef15d1a653f 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,20 @@ const meta: Meta = { export default meta; type Story = StoryObj; +export const CannotCreateWorkspace: Story = { + args: { + workspaces: [], + count: 0, + canCreateWorkspace: false, + canCreateTemplate: 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 AllStates: Story = { args: { workspaces: allWorkspaces, diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 6f0fb34ca67..aa5c4da077e 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 95158700332..77302cac88e 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, + createWorkspaceInAnyOrganization: true, deleteTemplates: true, updateTemplates: true, viewAllUsers: true, @@ -3333,6 +3334,7 @@ export const MockPermissions: Permissions = { export const MockNoPermissions: Permissions = { createTemplates: false, createUser: false, + createWorkspaceInAnyOrganization: false, deleteTemplates: false, updateTemplates: false, viewAllUsers: false, From c111fec2339b30a5a629802dd2cecb350bf9895e Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 15 Jul 2026 23:36:01 +0000 Subject: [PATCH 02/20] chore(site/src/testHelpers): default story permissions to MockPermissions withAuthProvider previously seeded the authcheck query with the raw parameters.permissions object, so any permission key a story did not list was undefined and therefore falsy. Every new key added to site/permissions.json silently rendered page-level stories as if the viewer lacked that permission; the WorkspacesPage Empty story broke this way when createWorkspaceInAnyOrganization was added. parameters.permissions is now a partial override merged over MockPermissions, so stories state only the restrictions they are about. --- site/src/testHelpers/storybook.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 6a885776d1e..16d9800fd2d 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -23,6 +23,7 @@ import { MockDeploymentConfig, MockEntitlements, MockOrganizationPermissions, + MockPermissions, MockProxyLatencies, } from "./entities"; @@ -166,7 +167,10 @@ export const withAuthProvider = (Story: FC, { parameters }: StoryContext) => { queryClient.setQueryData(hasFirstUserKey, true); queryClient.setQueryData( getAuthorizationKey({ checks: permissionChecks }), - parameters.permissions ?? {}, + // parameters.permissions is a partial override; unspecified keys fall + // back to the full-permission mock so stories track new permission + // keys without listing every one. + { ...MockPermissions, ...parameters.permissions }, ); return ( From 1e7270d156518affb1d655fb9a4af73248a63e8c Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 15 Jul 2026 23:36:10 +0000 Subject: [PATCH 03/20] feat(site/src/pages/CreateWorkspacePage): require workspace-create permission to view page The Workspaces page hides the New workspace button from users without create permission, but the creation form itself was reachable by direct URL; a permission-less user could fill it out and only hit a 403 on submit. Add an org-scoped createWorkspaceForUserID check to the page's existing authorization batch and wrap the view in RequirePermission, matching how other restricted pages deny access. Users who can create workspaces for others (createWorkspaceForAny) still see the form. An errored permission check is not treated as a denial; the request error surfaces in the view instead. --- .../CreateWorkspacePage.stories.tsx | 1 + .../CreateWorkspacePage.test.tsx | 7 +- .../CreateWorkspacePage.tsx | 124 ++++++++++-------- .../CreateWorkspacePageView.stories.tsx | 1 + .../pages/CreateWorkspacePage/permissions.ts | 9 ++ 5 files changed, 85 insertions(+), 57 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 87fb8947d2c..843010e3290 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -63,6 +63,7 @@ const meta: Meta = { spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion); spyOn(API, "getTemplateVersionPresets").mockResolvedValue(null); spyOn(API, "checkAuthorization").mockResolvedValue({ + createWorkspaceForUserID: true, createWorkspaceForAny: true, canUpdateTemplate: false, }); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index 7e28018cc23..22700cb48cd 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, @@ -222,7 +221,11 @@ describe("CreateWorkspacePage", () => { vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); vi.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([]); vi.spyOn(API, "createWorkspace").mockResolvedValue(MockWorkspace); - vi.spyOn(API, "checkAuthorization").mockResolvedValue(MockPermissions); + vi.spyOn(API, "checkAuthorization").mockResolvedValue({ + createWorkspaceForUserID: true, + createWorkspaceForAny: true, + canUpdateTemplate: false, + }); }); afterEach(() => { diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 3636a262ced..ff056a72f9f 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -27,6 +27,7 @@ import type { import { Loader } from "#/components/Loader/Loader"; 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,6 +90,7 @@ const CreateWorkspacePage: FC = () => { ...checkAuthorization({ checks: createWorkspaceChecks( templateQuery.data?.organization_id ?? "", + me.id, templateQuery.data?.id, ), }), @@ -406,63 +408,75 @@ const CreateWorkspacePage: FC = () => { {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 fb4b889ce7d..b516ebf33d5 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 a5ca3a469f6..e4dea942d70 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, From 3ea817ad53fbae88bad927618a2a7d5050da5043 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 15 Jul 2026 23:36:20 +0000 Subject: [PATCH 04/20] fix(site/src/pages/WorkspacesPage): show no-permission empty state before filter empty state A user without create permission arriving with an active filter in the URL saw "No results matched your search" instead of the no-permission message. Check canCreateWorkspace before isUsingFilter in WorkspacesEmpty. Add story coverage for the button being hidden while workspaces are listed, and for the empty-state branch priority; make canCreateWorkspace explicit in the empty-state stories instead of relying on the meta default, and drop a no-op canCreateTemplate arg. --- .../pages/WorkspacesPage/WorkspacesEmpty.tsx | 8 ++-- .../WorkspacesPageView.stories.tsx | 37 ++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx b/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx index ef15d1a653f..a114ffaec66 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx @@ -32,10 +32,6 @@ export const WorkspacesEmpty: FC = ({ ); - if (isUsingFilter) { - return ; - } - if (!canCreateWorkspace) { return ( = ({ ); } + if (isUsingFilter) { + return ; + } + if (templates && templates.length === 0 && canCreateTemplate) { return ( { const canvas = within(canvasElement); @@ -205,6 +204,38 @@ export const CannotCreateWorkspace: Story = { }, }; +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 no-permission empty state takes priority over the filter empty + // state. + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText(/don't have permission to create workspaces/i); + expect(canvas.queryByText(/no results matched your search/i)).toBeNull(); + }, +}; + export const AllStates: Story = { args: { workspaces: allWorkspaces, @@ -262,6 +293,7 @@ export const OwnerHasNoWorkspaces: Story = { workspaces: [], count: 0, canCreateTemplate: true, + canCreateWorkspace: true, }, }; @@ -271,6 +303,7 @@ export const OwnerHasNoWorkspacesAndNoTemplates: Story = { templates: [], count: 0, canCreateTemplate: true, + canCreateWorkspace: true, }, }; @@ -279,6 +312,7 @@ export const UserHasNoWorkspaces: Story = { workspaces: [], count: 0, canCreateTemplate: false, + canCreateWorkspace: true, }, }; @@ -288,6 +322,7 @@ export const UserHasNoWorkspacesAndNoTemplates: Story = { templates: [], count: 0, canCreateTemplate: false, + canCreateWorkspace: true, }, }; From aa8639401072653b60685d74c8dd0a0c5dfb3c2f Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Sun, 19 Jul 2026 22:47:54 +0000 Subject: [PATCH 05/20] refactor(site): rename createWorkspaceInAnyOrganization permission to createWorkspace Sibling checks with the same object shape encode scope in the check definition rather than the key name (createChat is also any_org + owner_id: me), so follow that convention. --- site/permissions.json | 2 +- site/src/pages/WorkspacesPage/WorkspacesPage.tsx | 2 +- site/src/testHelpers/entities.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/site/permissions.json b/site/permissions.json index 1363688e489..71b91a51b12 100644 --- a/site/permissions.json +++ b/site/permissions.json @@ -15,7 +15,7 @@ "object": { "resource_type": "template", "any_org": true }, "action": "create" }, - "createWorkspaceInAnyOrganization": { + "createWorkspace": { "object": { "resource_type": "workspace", "any_org": true, diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index c7c961ce3a0..2c572231f0c 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -169,7 +169,7 @@ const WorkspacesPage: FC = () => { Date: Wed, 22 Jul 2026 23:06:43 +0000 Subject: [PATCH 06/20] test(site): cover the CreateWorkspacePage permission gate and createWorkspace SSR check Add PermissionDenied (both create checks false -> RequirePermission dialog, no form) and CanCreateForOthersOnly (createWorkspaceForAny alone still shows the form) stories with play assertions. Assert the createWorkspace entry in TestRenderPermissionsResolvesMe: true for an org member, false for a user with no org membership. --- site/site_test.go | 7 +++ .../CreateWorkspacePage.stories.tsx | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/site/site_test.go b/site/site_test.go index e8f693001e0..951d88d1054 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,9 @@ 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") } func TestInjectionFailureProducesCleanHTML(t *testing.T) { diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 843010e3290..223d42494c7 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -227,3 +227,50 @@ export const SequentialAuthFlow: Story = { }); }, }; + +/** + * A user who can create workspaces neither for themselves nor for others + * 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(/permission to view this page/i); + expect( + within(canvasElement).queryByRole("form", { + name: /create workspace/i, + }), + ).toBeNull(); + }, +}; + +/** + * A user who can create workspaces for others but not for themselves + * still sees the form. + */ +export const CanCreateForOthersOnly: Story = { + beforeEach: () => { + spyOn(API, "checkAuthorization").mockResolvedValue({ + createWorkspaceForUserID: false, + createWorkspaceForAny: true, + canUpdateTemplate: false, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole("form", { name: /create workspace/i }); + expect( + within(canvasElement.ownerDocument.body).queryByText( + /permission to view this page/i, + ), + ).toBeNull(); + }, +}; From 10576a2b4a74de992803a00411565f5a8ce86530 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 23 Jul 2026 15:09:44 +0000 Subject: [PATCH 07/20] test(site): assert the full RequirePermission dialog message Match "You don't have permission to view this page" exactly instead of a fragment, so the assertion fails if the dialog copy diverges. --- .../pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 223d42494c7..7b6da8694df 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -243,7 +243,7 @@ export const PermissionDenied: Story = { play: async ({ canvasElement }) => { // The dialog renders in a portal outside the story canvas. const body = within(canvasElement.ownerDocument.body); - await body.findByText(/permission to view this page/i); + await body.findByText(/you don't have permission to view this page/i); expect( within(canvasElement).queryByRole("form", { name: /create workspace/i, @@ -269,7 +269,7 @@ export const CanCreateForOthersOnly: Story = { await canvas.findByRole("form", { name: /create workspace/i }); expect( within(canvasElement.ownerDocument.body).queryByText( - /permission to view this page/i, + /you don't have permission to view this page/i, ), ).toBeNull(); }, From 6be8e19a3aee8385faa4b86e3196b255ae24b7b1 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 23 Jul 2026 17:46:36 +0000 Subject: [PATCH 08/20] fix(site/src/pages/CreateWorkspacePage): fire onOpen in the dynamic parameters story mock The page only leaves its loading state after the socket's onOpen callback runs: onOpen sends the initial parameters and records the response ID the form must wait for (initId stays NaN until then, which keeps isInitializing true). The story mock predates that contract and only fired onMessage, so every story in the file hung on the loader and their play assertions timed out, including in Chromatic. Fire onOpen before delivering the server's initial id: -1 response, and report an OPEN readyState so sendMessage works. Fixes all six stories in the file, including the four external-auth stories broken since the contract change. --- .../CreateWorkspacePage.stories.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 7b6da8694df..0c613f908c1 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -17,20 +17,27 @@ import { import CreateWorkspacePage from "./CreateWorkspacePage"; /** - * Mocks API.templateVersionDynamicParameters to immediately send an empty - * DynamicParametersResponse so the page renders the form instead of the - * loader. + * Mocks API.templateVersionDynamicParameters so the page renders the form + * instead of the loader. The page waits for the socket to open (which sends + * the initial parameters and records the response ID to wait for) and then + * for a response with at least that ID, so the mock fires onOpen followed by + * the server's initial id: -1 response. */ function mockDynamicParameters() { spyOn(API, "templateVersionDynamicParameters").mockImplementation( (_versionId, _ownerId, callbacks) => { - // Fire asynchronously so the component mounts before the message - // arrives, matching real WebSocket behavior. + // Fire asynchronously so the component mounts before the socket + // events arrive, matching real WebSocket behavior. setTimeout(() => { - callbacks.onMessage({ id: 0, parameters: [], diagnostics: [] }); + callbacks.onOpen?.(); + callbacks.onMessage({ id: -1, parameters: [], diagnostics: [] }); }, 0); - return { close: () => {} } as unknown as WebSocket; + return { + readyState: WebSocket.OPEN, + send: () => {}, + close: () => {}, + } as unknown as WebSocket; }, ); } From 723e3b14d0e0c78ee1a1f5bba03f46e626673279 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 23 Jul 2026 17:54:06 +0000 Subject: [PATCH 09/20] refactor(site/src/pages/CreateWorkspacePage): use withWebSocket decorator in stories Replace the file's bespoke templateVersionDynamicParameters spy with the withWebSocket decorator and declared socket events, the same mechanism WorkspaceParametersPage stories use. The declared events are open followed by the server's initial id: -1 response. --- .../CreateWorkspacePage.stories.tsx | 43 +++++++------------ 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 0c613f908c1..d09453e2fd8 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,42 +13,31 @@ import { import { withAuthProvider, withDashboardProvider, + withWebSocket, } from "#/testHelpers/storybook"; import CreateWorkspacePage from "./CreateWorkspacePage"; -/** - * Mocks API.templateVersionDynamicParameters so the page renders the form - * instead of the loader. The page waits for the socket to open (which sends - * the initial parameters and records the response ID to wait for) and then - * for a response with at least that ID, so the mock fires onOpen followed by - * the server's initial id: -1 response. - */ -function mockDynamicParameters() { - spyOn(API, "templateVersionDynamicParameters").mockImplementation( - (_versionId, _ownerId, callbacks) => { - // Fire asynchronously so the component mounts before the socket - // events arrive, matching real WebSocket behavior. - setTimeout(() => { - callbacks.onOpen?.(); - callbacks.onMessage({ id: -1, parameters: [], diagnostics: [] }); - }, 0); - - return { - readyState: WebSocket.OPEN, - send: () => {}, - 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: { @@ -75,8 +64,8 @@ const meta: Meta = { 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([]); From 09d6418ed7382b064bd4f6883fc311a742bf8f10 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 23 Jul 2026 19:23:02 +0000 Subject: [PATCH 10/20] fix(site/src/testHelpers): default story permissions to MockNoPermissions Merging over MockPermissions changed the rendered persona of every story that encoded a restriction by omitting a permission key (for example WorkspaceWildcardWarning's WithoutEditPermission rendered the with-permission copy, and the TaskPage sidebar gained the viewAllUsers combobox), producing hundreds of Chromatic diffs. Merge over MockNoPermissions instead: unspecified keys are explicitly false, matching the previous seeded-raw behavior while keeping the object complete, and stories that depend on a granted permission must list it - as WorkspacesPage now does for createWorkspace. --- .../pages/WorkspacesPage/WorkspacesPage.stories.tsx | 1 + site/src/testHelpers/storybook.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx index 210d8bf6d16..1640c8a275c 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx @@ -74,6 +74,7 @@ const meta = { user: MockUserOwner, permissions: { viewDeploymentConfig: false, + createWorkspace: true, }, queries: [ { diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 16d9800fd2d..ef986f25816 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -22,8 +22,8 @@ import { MockDefaultOrganization, MockDeploymentConfig, MockEntitlements, + MockNoPermissions, MockOrganizationPermissions, - MockPermissions, MockProxyLatencies, } from "./entities"; @@ -167,10 +167,11 @@ export const withAuthProvider = (Story: FC, { parameters }: StoryContext) => { queryClient.setQueryData(hasFirstUserKey, true); queryClient.setQueryData( getAuthorizationKey({ checks: permissionChecks }), - // parameters.permissions is a partial override; unspecified keys fall - // back to the full-permission mock so stories track new permission - // keys without listing every one. - { ...MockPermissions, ...parameters.permissions }, + // parameters.permissions is a partial override; unspecified keys are + // explicitly false so the seeded object always contains every + // permission key. Stories that depend on a permission being granted + // must list it. + { ...MockNoPermissions, ...parameters.permissions }, ); return ( From b5d9334d3ba351605bbc67b018d6ca752beac59d Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 23 Jul 2026 19:42:13 +0000 Subject: [PATCH 11/20] revert(site/src/testHelpers): restore raw story permissions seeding Both merge defaults changed rendering or reviewer-visible snapshot behavior across unrelated stories; seed parameters.permissions raw exactly as before. Stories cover missing keys explicitly instead (WorkspacesPage sets createWorkspace: true). --- site/src/testHelpers/storybook.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index ef986f25816..6a885776d1e 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -22,7 +22,6 @@ import { MockDefaultOrganization, MockDeploymentConfig, MockEntitlements, - MockNoPermissions, MockOrganizationPermissions, MockProxyLatencies, } from "./entities"; @@ -167,11 +166,7 @@ export const withAuthProvider = (Story: FC, { parameters }: StoryContext) => { queryClient.setQueryData(hasFirstUserKey, true); queryClient.setQueryData( getAuthorizationKey({ checks: permissionChecks }), - // parameters.permissions is a partial override; unspecified keys are - // explicitly false so the seeded object always contains every - // permission key. Stories that depend on a permission being granted - // must list it. - { ...MockNoPermissions, ...parameters.permissions }, + parameters.permissions ?? {}, ); return ( From 391403e295cf70f718b6fb6a79ae80855a7429ef Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Fri, 24 Jul 2026 20:23:36 +0000 Subject: [PATCH 12/20] fix(site): address review on empty-state priority and unreachable permission state Restore the filter empty state's priority in WorkspacesEmpty: an active filter that matched nothing shows "no results" regardless of create permission, since the user may own workspaces the filter excluded. Repoint the CannotCreateWorkspaceWithFilter story at the restored order. Drop createWorkspaceForAny from the CreateWorkspacePage guard and remove the CanCreateForOthersOnly story: an org- or site-scoped grant that satisfies owner_id "*" is owner-agnostic, so it also satisfies the owner_id: me check - createWorkspaceForAny cannot be true while createWorkspaceForUserID is false. --- .../CreateWorkspacePage.stories.tsx | 27 ++----------------- .../CreateWorkspacePage.tsx | 5 +--- .../pages/WorkspacesPage/WorkspacesEmpty.tsx | 8 +++--- .../WorkspacesPageView.stories.tsx | 12 ++++++--- 4 files changed, 15 insertions(+), 37 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index d09453e2fd8..800695affe3 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -225,8 +225,8 @@ export const SequentialAuthFlow: Story = { }; /** - * A user who can create workspaces neither for themselves nor for others - * is blocked by the RequirePermission dialog instead of seeing the form. + * A user without workspace-create permission is blocked by the + * RequirePermission dialog instead of seeing the form. */ export const PermissionDenied: Story = { beforeEach: () => { @@ -247,26 +247,3 @@ export const PermissionDenied: Story = { ).toBeNull(); }, }; - -/** - * A user who can create workspaces for others but not for themselves - * still sees the form. - */ -export const CanCreateForOthersOnly: Story = { - beforeEach: () => { - spyOn(API, "checkAuthorization").mockResolvedValue({ - createWorkspaceForUserID: false, - createWorkspaceForAny: true, - canUpdateTemplate: false, - }); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await canvas.findByRole("form", { name: /create workspace/i }); - expect( - within(canvasElement.ownerDocument.body).queryByText( - /you don't have permission to view this page/i, - ), - ).toBeNull(); - }, -}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index ff056a72f9f..d013268673b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -413,10 +413,7 @@ const CreateWorkspacePage: FC = () => { // page so the request error is surfaced instead. isFeatureVisible={ permissionsQuery.isError || - Boolean( - permissionsQuery.data?.createWorkspaceForUserID || - permissionsQuery.data?.createWorkspaceForAny, - ) + Boolean(permissionsQuery.data?.createWorkspaceForUserID) } > = ({ ); + if (isUsingFilter) { + return ; + } + if (!canCreateWorkspace) { return ( = ({ ); } - if (isUsingFilter) { - return ; - } - if (templates && templates.length === 0 && canCreateTemplate) { return ( { const canvas = within(canvasElement); - await canvas.findByText(/don't have permission to create workspaces/i); - expect(canvas.queryByText(/no results matched your search/i)).toBeNull(); + 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(); }, }; From d2694429265114242ccf3e28a3da035db712f3b5 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Mon, 27 Jul 2026 21:59:34 +0000 Subject: [PATCH 13/20] fix(site/src/pages/CreateWorkspacePage): gate auto-create and handle permission check errors The RequirePermission wrapper only covered the form: with ?mode=auto a denied user still saw the consent dialog and could trigger the auto-create request. Require createWorkspaceForUserID in autoCreateReady and showAutoCreateConsent, so a denied auto-mode visit goes straight to the permission dialog. The isError passthrough rendered the view with undefined permission data, which the view reads unconditionally, crashing instead of surfacing the error. Render an ErrorAlert for a failed permission check instead. Cover both in unit tests plus the denied form-mode dialog, and add a PermissionDeniedAutoMode story. --- .../CreateWorkspacePage.stories.tsx | 34 ++++++++++ .../CreateWorkspacePage.test.tsx | 68 +++++++++++++++++++ .../CreateWorkspacePage.tsx | 22 +++--- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 800695affe3..c004e152ab2 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -247,3 +247,37 @@ export const PermissionDenied: Story = { ).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 22700cb48cd..5a5a5180058 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -639,6 +639,74 @@ describe("CreateWorkspacePage", () => { }); }); + describe("Permissions", () => { + const deniedPermissions = { + createWorkspaceForUserID: false, + createWorkspaceForAny: false, + canUpdateTemplate: false, + }; + + it("blocks the form behind a permission dialog when the user cannot create workspaces", async () => { + vi.spyOn(API, "checkAuthorization").mockResolvedValue(deniedPermissions); + + 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 () => { + vi.spyOn(API, "checkAuthorization").mockResolvedValue(deniedPermissions); + 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 new Error("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("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 d013268673b..d7b6ef88cbc 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -24,7 +24,9 @@ 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"; @@ -96,6 +98,9 @@ const CreateWorkspacePage: FC = () => { }), enabled: Boolean(templateQuery.data), }); + const canCreateWorkspace = Boolean( + permissionsQuery.data?.createWorkspaceForUserID, + ); const templateVersionQuery = useQuery({ ...templateVersion(realizedVersionId ?? ""), @@ -311,12 +316,14 @@ const CreateWorkspacePage: FC = () => { let autoCreateReady = mode === "auto" && + canCreateWorkspace && hasAllRequiredExternalAuth && autoCreateConsented && presetResolved; const showAutoCreateConsent = mode === "auto" && + canCreateWorkspace && !autoCreateConsented && !autoCreateError && presetResolved; @@ -407,15 +414,14 @@ const CreateWorkspacePage: FC = () => { {shouldShowLoader ? ( + ) : permissionsQuery.isError ? ( + // The view reads the permission results unconditionally, so a + // failed check renders an error instead of the form. + + + ) : ( - + Date: Mon, 27 Jul 2026 22:38:37 +0000 Subject: [PATCH 14/20] fix(site/src/pages/CreateWorkspacePage): surface template load errors instead of the loader A failed template query left the page on its loader forever: shouldShowLoader includes !templateQuery.data, and loadFormDataError was only passed into the view, which never rendered. Check loadFormDataError before the loader and render a page-level ErrorAlert, covering both template and permission query failures, and drop the now-unreachable loadFormDataError from the view's error chain. --- .../CreateWorkspacePage.test.tsx | 17 +++++++++++++++++ .../CreateWorkspacePage/CreateWorkspacePage.tsx | 14 +++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index 5a5a5180058..e6ed9b06814 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -707,6 +707,23 @@ describe("CreateWorkspacePage", () => { }); }); + describe("Load Errors", () => { + it("shows an error instead of the loader when the template fails to load", async () => { + vi.spyOn(API, "getTemplateByName").mockRejectedValue( + new Error("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 d7b6ef88cbc..8e75fd60253 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -412,14 +412,15 @@ const CreateWorkspacePage: FC = () => { onDeny={() => setMode("form")} /> - {shouldShowLoader ? ( - - ) : permissionsQuery.isError ? ( - // The view reads the permission results unconditionally, so a - // failed check renders an error instead of the form. + {loadFormDataError ? ( + // The view reads the template and permission results + // unconditionally, so render query failures as a page-level + // error instead of the form. - + + ) : shouldShowLoader ? ( + ) : ( { wsError || createWorkspaceMutation.error || autoCreateError || - loadFormDataError || autoCreateWorkspaceMutation.error } resetMutation={createWorkspaceMutation.reset} From 4c8432897fdb48abd06b91357c2c86e78e670777 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 28 Jul 2026 02:19:10 +0000 Subject: [PATCH 15/20] test(CreateWorkspacePage): model checkAuthorization request-keyed responses --- .../CreateWorkspacePage.test.tsx | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index e6ed9b06814..e2474f3ab4f 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -20,6 +20,7 @@ import { MockUserOwner, MockValidationParameter, MockWorkspace, + mockApiError, } from "#/testHelpers/entities"; import { checkParameters, editParameters } from "#/testHelpers/parameters"; import { @@ -54,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. @@ -221,10 +234,9 @@ describe("CreateWorkspacePage", () => { vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); vi.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([]); vi.spyOn(API, "createWorkspace").mockResolvedValue(MockWorkspace); - vi.spyOn(API, "checkAuthorization").mockResolvedValue({ + mockCheckAuthorization({ createWorkspaceForUserID: true, createWorkspaceForAny: true, - canUpdateTemplate: false, }); }); @@ -640,14 +652,8 @@ describe("CreateWorkspacePage", () => { }); describe("Permissions", () => { - const deniedPermissions = { - createWorkspaceForUserID: false, - createWorkspaceForAny: false, - canUpdateTemplate: false, - }; - it("blocks the form behind a permission dialog when the user cannot create workspaces", async () => { - vi.spyOn(API, "checkAuthorization").mockResolvedValue(deniedPermissions); + mockCheckAuthorization(); const { mockPublisher } = await renderPageWithSocket({}); await expectSocketHandshake({ mockPublisher, parameters: [] }); @@ -661,7 +667,7 @@ describe("CreateWorkspacePage", () => { }); it("blocks auto-creation without showing the consent dialog when the user cannot create workspaces", async () => { - vi.spyOn(API, "checkAuthorization").mockResolvedValue(deniedPermissions); + mockCheckAuthorization(); const autoCreateSpy = vi.spyOn(API, "createWorkspace"); const { mockPublisher } = await renderPageWithSocket({ @@ -684,7 +690,9 @@ describe("CreateWorkspacePage", () => { vi.spyOn(API, "checkAuthorization").mockImplementation( async ({ checks }) => { if ("createWorkspaceForUserID" in checks) { - throw new Error("failed to check authorization"); + throw mockApiError({ + message: "failed to check authorization", + }); } return {}; }, @@ -710,7 +718,7 @@ describe("CreateWorkspacePage", () => { describe("Load Errors", () => { it("shows an error instead of the loader when the template fails to load", async () => { vi.spyOn(API, "getTemplateByName").mockRejectedValue( - new Error("failed to load template"), + mockApiError({ message: "failed to load template" }), ); renderCreateWorkspacePage(); From cbf832996ecc5c5bd3f17629ead227cc2f42bc4c Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 28 Jul 2026 02:42:57 +0000 Subject: [PATCH 16/20] fix(site/src/pages/CreateWorkspacePage): keep loader up until permission data exists --- site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 8e75fd60253..8448720c8e8 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -266,7 +266,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 From 0a554957866d7abded75526c853a1ba1e5525736 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 28 Jul 2026 02:48:21 +0000 Subject: [PATCH 17/20] test(site/src/pages/WorkspacesPage): cover New workspace button hiding and empty states --- .../WorkspacesPageView.test.tsx | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx new file mode 100644 index 00000000000..feb87c40ae7 --- /dev/null +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx @@ -0,0 +1,97 @@ +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(); + }); +}); From db32f8b743ead6a0448e1690cb753f610c5a8cfd Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 28 Jul 2026 22:44:19 +0000 Subject: [PATCH 18/20] test(site/src/pages/WorkspacesPage): cover filter empty state with create permission --- .../WorkspacesPage/WorkspacesPageView.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx index feb87c40ae7..c87395a8888 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx @@ -94,4 +94,18 @@ describe("WorkspacesPageView", () => { 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(); + }); }); From 17cacb94bad502e8b4a0fbc266a76bbfd1ba10b4 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 28 Jul 2026 22:53:15 +0000 Subject: [PATCH 19/20] refactor(site/src/pages/CreateWorkspacePage): clarify org-scoped create permission --- .../pages/CreateWorkspacePage/CreateWorkspacePage.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 8448720c8e8..d818a887f9d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -98,7 +98,10 @@ const CreateWorkspacePage: FC = () => { }), enabled: Boolean(templateQuery.data), }); - const canCreateWorkspace = Boolean( + // 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, ); @@ -319,14 +322,14 @@ const CreateWorkspacePage: FC = () => { let autoCreateReady = mode === "auto" && - canCreateWorkspace && + canCreateWorkspaceInOrg && hasAllRequiredExternalAuth && autoCreateConsented && presetResolved; const showAutoCreateConsent = mode === "auto" && - canCreateWorkspace && + canCreateWorkspaceInOrg && !autoCreateConsented && !autoCreateError && presetResolved; @@ -425,7 +428,7 @@ const CreateWorkspacePage: FC = () => { ) : shouldShowLoader ? ( ) : ( - + Date: Tue, 28 Jul 2026 22:55:08 +0000 Subject: [PATCH 20/20] test(site): assert the workspace-creation ban denies the SSR createWorkspace check --- site/site_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/site/site_test.go b/site/site_test.go index 951d88d1054..bb66ecaf9a6 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -303,6 +303,33 @@ func TestRenderPermissionsResolvesMe(t *testing.T) { // 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) {