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.
This commit is contained in:
J. Scott Miller
2026-07-28 18:09:54 -05:00
committed by GitHub
parent efbf802319
commit ce769680ba
15 changed files with 501 additions and 85 deletions
+8
View File
@@ -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"
+34
View File
@@ -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) {
@@ -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<typeof CreateWorkspacePage> = {
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<typeof CreateWorkspacePage> = {
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();
},
};
@@ -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<string, boolean> = {}) =>
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 = [
@@ -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.
<Margins>
<ErrorAlert error={loadFormDataError} className="my-4" />
</Margins>
) : shouldShowLoader ? (
<Loader />
) : (
<CreateWorkspacePageView
mode={mode}
defaultName={defaultName}
diagnostics={latestResponse?.diagnostics ?? []}
disabledParams={disabledParams}
defaultOwner={defaultOwner}
owner={owner}
setOwner={setOwner}
autofillParameters={autofillParameters}
canUpdateTemplate={permissionsQuery.data?.canUpdateTemplate}
error={
wsError ||
createWorkspaceMutation.error ||
autoCreateError ||
loadFormDataError ||
autoCreateWorkspaceMutation.error
}
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,
};
<RequirePermission isFeatureVisible={canCreateWorkspaceInOrg}>
<CreateWorkspacePageView
mode={mode}
defaultName={defaultName}
diagnostics={latestResponse?.diagnostics ?? []}
disabledParams={disabledParams}
defaultOwner={defaultOwner}
owner={owner}
setOwner={setOwner}
autofillParameters={autofillParameters}
canUpdateTemplate={permissionsQuery.data?.canUpdateTemplate}
error={
wsError ||
createWorkspaceMutation.error ||
autoCreateError ||
autoCreateWorkspaceMutation.error
}
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);
}}
/>
const workspace = await createWorkspaceMutation.mutateAsync({
...workspaceRequest,
userId: owner.id,
});
onCreateWorkspace(workspace);
}}
/>
</RequirePermission>
)}
</>
);
@@ -27,6 +27,7 @@ const meta: Meta<typeof CreateWorkspacePageView> = {
mode: "form",
parameters: [],
permissions: {
createWorkspaceForUserID: true,
createWorkspaceForAny: true,
canUpdateTemplate: false,
},
@@ -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,
@@ -10,12 +10,14 @@ interface WorkspacesEmptyProps {
isUsingFilter: boolean;
templates?: Template[];
canCreateTemplate: boolean;
canCreateWorkspace: boolean;
}
export const WorkspacesEmpty: FC<WorkspacesEmptyProps> = ({
isUsingFilter,
templates,
canCreateTemplate,
canCreateWorkspace,
}) => {
const getLink = useLinks();
@@ -34,6 +36,17 @@ export const WorkspacesEmpty: FC<WorkspacesEmptyProps> = ({
return <EmptyState message="No results matched your search" />;
}
if (!canCreateWorkspace) {
return (
<EmptyState
message="No workspaces"
description="You don't have permission to create workspaces. Contact your administrator if you need workspace access."
className="pb-0"
image={defaultImage}
/>
);
}
if (templates && templates.length === 0 && canCreateTemplate) {
return (
<EmptyState
@@ -74,6 +74,7 @@ const meta = {
user: MockUserOwner,
permissions: {
viewDeploymentConfig: false,
createWorkspace: true,
},
queries: [
{
@@ -169,6 +169,7 @@ const WorkspacesPage: FC = () => {
<WorkspacesPageView
canCreateTemplate={permissions.createTemplates}
canCreateWorkspace={permissions.createWorkspace}
canChangeVersions={permissions.updateTemplates}
checkedWorkspaces={checkedWorkspaces}
chatsByWorkspace={chatsByWorkspaceQuery.data}
@@ -172,6 +172,7 @@ const meta: Meta<typeof WorkspacesPageView> = {
checkedWorkspaces: [],
templates: mockTemplates,
templatesFetchStatus: "success",
canCreateWorkspace: true,
count: 13,
page: 1,
},
@@ -190,6 +191,55 @@ const meta: Meta<typeof WorkspacesPageView> = {
export default meta;
type Story = StoryObj<typeof WorkspacesPageView>;
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,
},
};
@@ -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(
<WorkspacesPageView {...defaultProps} canCreateWorkspace={false} />,
);
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(<WorkspacesPageView {...defaultProps} />);
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(
<WorkspacesPageView
{...defaultProps}
canCreateWorkspace={false}
filterState={createFilterState(true)}
/>,
);
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(
<WorkspacesPageView
{...defaultProps}
filterState={createFilterState(true)}
/>,
);
await screen.findByText(/no results matched your search/i);
expect(
screen.queryByText(/don't have permission to create workspaces/i),
).not.toBeInTheDocument();
});
});
@@ -51,6 +51,7 @@ interface WorkspacesPageViewProps {
templatesFetchStatus: TemplateQuery["status"];
templates: TemplateQuery["data"];
canCreateTemplate: boolean;
canCreateWorkspace: boolean;
canChangeVersions: boolean;
onActionSuccess: () => Promise<void>;
onActionError: (error: unknown) => void;
@@ -75,6 +76,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
templates,
templatesFetchStatus,
canCreateTemplate,
canCreateWorkspace,
canChangeVersions,
onActionSuccess,
onActionError,
@@ -89,12 +91,14 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
<Margins className="pb-12">
<PageHeader
actions={
<WorkspacesButton
templates={templates}
templatesFetchStatus={templatesFetchStatus}
>
New workspace
</WorkspacesButton>
canCreateWorkspace && (
<WorkspacesButton
templates={templates}
templatesFetchStatus={templatesFetchStatus}
>
New workspace
</WorkspacesButton>
)
}
>
<PageHeaderTitle>
@@ -212,6 +216,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
) : (
<WorkspacesTable
canCreateTemplate={canCreateTemplate}
canCreateWorkspace={canCreateWorkspace}
workspaces={workspaces}
isUsingFilter={filterState.filter.used}
checkedWorkspaces={checkedWorkspaces}
@@ -94,6 +94,7 @@ interface WorkspacesTableProps {
onCheckChange: (checkedWorkspaces: readonly Workspace[]) => void;
templates?: Template[];
canCreateTemplate: boolean;
canCreateWorkspace: boolean;
onActionSuccess: () => Promise<void>;
onActionError: (error: unknown) => void;
chatsByWorkspace?: Record<string, string>;
@@ -106,6 +107,7 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
onCheckChange,
templates,
canCreateTemplate,
canCreateWorkspace,
onActionSuccess,
onActionError,
chatsByWorkspace,
@@ -168,6 +170,7 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
templates={templates}
isUsingFilter={isUsingFilter}
canCreateTemplate={canCreateTemplate}
canCreateWorkspace={canCreateWorkspace}
/>
</TableCell>
</TableRow>
+2
View File
@@ -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,