Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
92a858d
feat(site/src/pages/WorkspacesPage): hide New workspace button withou…
jscottmiller Jul 8, 2026
c111fec
chore(site/src/testHelpers): default story permissions to MockPermiss…
jscottmiller Jul 15, 2026
1e7270d
feat(site/src/pages/CreateWorkspacePage): require workspace-create pe…
jscottmiller Jul 15, 2026
3ea817a
fix(site/src/pages/WorkspacesPage): show no-permission empty state be…
jscottmiller Jul 15, 2026
aa86394
refactor(site): rename createWorkspaceInAnyOrganization permission to…
jscottmiller Jul 19, 2026
23b1863
test(site): cover the CreateWorkspacePage permission gate and createW…
jscottmiller Jul 22, 2026
10576a2
test(site): assert the full RequirePermission dialog message
jscottmiller Jul 23, 2026
6be8e19
fix(site/src/pages/CreateWorkspacePage): fire onOpen in the dynamic p…
jscottmiller Jul 23, 2026
723e3b1
refactor(site/src/pages/CreateWorkspacePage): use withWebSocket decor…
jscottmiller Jul 23, 2026
09d6418
fix(site/src/testHelpers): default story permissions to MockNoPermiss…
jscottmiller Jul 23, 2026
b5d9334
revert(site/src/testHelpers): restore raw story permissions seeding
jscottmiller Jul 23, 2026
391403e
fix(site): address review on empty-state priority and unreachable per…
jscottmiller Jul 24, 2026
d269442
fix(site/src/pages/CreateWorkspacePage): gate auto-create and handle …
jscottmiller Jul 27, 2026
efc36f6
fix(site/src/pages/CreateWorkspacePage): surface template load errors…
jscottmiller Jul 27, 2026
4c84328
test(CreateWorkspacePage): model checkAuthorization request-keyed res…
jscottmiller Jul 28, 2026
cbf8329
fix(site/src/pages/CreateWorkspacePage): keep loader up until permiss…
jscottmiller Jul 28, 2026
0a55495
test(site/src/pages/WorkspacesPage): cover New workspace button hidin…
jscottmiller Jul 28, 2026
db32f8b
test(site/src/pages/WorkspacesPage): cover filter empty state with cr…
jscottmiller Jul 28, 2026
17cacb9
refactor(site/src/pages/CreateWorkspacePage): clarify org-scoped crea…
jscottmiller Jul 28, 2026
61e61cf
test(site): assert the workspace-creation ban denies the SSR createWo…
jscottmiller Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions site/permissions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 34 additions & 0 deletions site/site_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand All @@ -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) {
Expand Down
95 changes: 75 additions & 20 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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: {
Expand All @@ -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([]);
Expand Down Expand Up @@ -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();
},
};
100 changes: 98 additions & 2 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
MockDropdownParameter,
MockDynamicParametersResponseWithError,
MockMultiSelectParameter,
MockPermissions,
MockPreviewParameter1,
MockPreviewParameter2,
MockPreviewParameter7,
Expand All @@ -21,6 +20,7 @@ import {
MockUserOwner,
MockValidationParameter,
MockWorkspace,
mockApiError,
} from "#/testHelpers/entities";
import { checkParameters, editParameters } from "#/testHelpers/parameters";
import {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 = [
Expand Down
Loading
Loading