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
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
4 changes: 4 additions & 0 deletions site/permissions.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
"object": { "resource_type": "template" },
"action": "update"
},
"updateAnyTemplate": {
"object": { "resource_type": "template", "any_org": true },
"action": "update"
},
"deleteTemplates": {
"object": { "resource_type": "template" },
"action": "delete"
Expand Down
1 change: 1 addition & 0 deletions site/src/modules/dashboard/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const Navbar: React.FC = () => {
permissions.createAnyMCPServerConfig ||
permissions.updateAnyMCPServerConfig ||
permissions.deleteAnyMCPServerConfig ||
permissions.updateAnyTemplate ||
canAccessAnyModel;
const organizationMCPSharing = useCanShareOrganizationMCPServers(
organizations,
Expand Down
46 changes: 46 additions & 0 deletions site/src/modules/dashboard/Navbar/NavbarView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,52 @@ export const ForOrgAdmin: Story = {
},
};

export const ForTemplateUpdateOnlyAdmin: Story = {
decorators: [withAuthProvider],
parameters: {
pixel: { matrix: pixelWithDesktop },
queries: [{ key: ["tasks", memberTasksFilter], data: [] }],
user: MockUserMember,
permissions: {
...MockNoPermissions,
updateAnyTemplate: true,
},
reactRouter: reactRouterParameters({
location: { path: "/" },
routing: [
{ path: "/", useStoryElement: true },
{
path: "/ai/settings",
element: <AISettingsIndexRedirectWithProviders />,
},
{
path: "/ai/settings/templates",
element: <h1>Templates</h1>,
},
],
}),
},
args: {
user: MockUserMember,
adminPermissions: {
canViewAISettings: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Admin settings" }),
);
const body = within(canvasElement.ownerDocument.body);
const aiSettingsLink = body.getByRole("menuitem", { name: "AI" });
await expect(aiSettingsLink).toHaveAttribute("href", "/ai/settings");
await userEvent.click(aiSettingsLink);
await expect(
await canvas.findByRole("heading", { name: "Templates" }),
).toBeInTheDocument();
},
};

export const ForMCPUpdateOnlyAdmin: Story = {
decorators: [withAuthProvider],
parameters: {
Expand Down
31 changes: 29 additions & 2 deletions site/src/modules/management/AISettingsSidebarView.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { organizationsPermissions } from "#/api/queries/organizations";
import {
Expand Down Expand Up @@ -190,6 +190,7 @@ export const NoDeploymentConfig: Story = {
permissions: {
...MockPermissions,
editDeploymentConfig: false,
updateAnyTemplate: false,
},
},
play: async ({ canvasElement }) => {
Expand All @@ -199,11 +200,37 @@ export const NoDeploymentConfig: Story = {
},
};

export const TemplatesForOrganizationAdmin: Story = {
args: {
permissions: {
...MockNoPermissions,
updateAnyTemplate: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const templatesLink = canvas.getByRole("link", { name: "Templates" });
await expect(templatesLink).toBeVisible();
await expect(
canvas.queryByRole("link", { name: "Coder Agents" }),
).not.toBeInTheDocument();

await userEvent.click(templatesLink);

await waitFor(() =>
expect(canvas.getByRole("link", { name: "Templates" })).toHaveAttribute(
"aria-current",
"page",
),
);
},
};

export const NoUpdateTemplates: Story = {
args: {
permissions: {
...MockPermissions,
updateTemplates: false,
updateAnyTemplate: false,
},
},
play: async ({ canvasElement }) => {
Expand Down
7 changes: 6 additions & 1 deletion site/src/modules/management/AISettingsSidebarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
{permissions.editDeploymentConfig && (
<div className="flex flex-col gap-1 ml-3 border-0 border-solid border-l border-l-border">
<SubNavItem href="/ai/settings/mcp-servers">MCP servers</SubNavItem>
{permissions.updateTemplates && (
{permissions.updateAnyTemplate && (
<SubNavItem href="/ai/settings/templates">Templates</SubNavItem>
)}
<SubNavItem href="/ai/settings/instructions">
Expand Down Expand Up @@ -120,6 +120,11 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
</SubNavItem>
</div>
)}
{!permissions.editDeploymentConfig && permissions.updateAnyTemplate && (
<div className="flex flex-col gap-1 ml-3 border-0 border-solid border-l border-l-border">
<SubNavItem href="/ai/settings/templates">Templates</SubNavItem>
</div>
Comment thread
ethanndickson marked this conversation as resolved.
)}
</div>
</BaseSidebar>
);
Expand Down
7 changes: 7 additions & 0 deletions site/src/modules/permissions/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ export const organizationPermissionChecks = (organizationId: string) =>
},
action: "update",
},
updateTemplates: {
object: {
resource_type: "template",
organization_id: organizationId,
},
action: "update",
},
viewMCPServerConfigs: {
object: {
resource_type: "mcp_server_config",
Expand Down
4 changes: 4 additions & 0 deletions site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export const AISettingsIndexRedirect = () => {
return <Navigate to="/ai/settings/mcp-servers/add" replace />;
}

if (permissions.updateAnyTemplate) {
return <Navigate to="/ai/settings/templates" replace />;
}

if (accessibleOrgsQuery.isLoading) {
return <Loader fullscreen />;
}
Expand Down
121 changes: 102 additions & 19 deletions site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import { organizationsPermissions } from "#/api/queries/organizations";
import { getTemplatesQueryKey } from "#/api/queries/templates";
import type { Template } from "#/api/typesGenerated";
import { createDeferred, type Deferred } from "#/testHelpers/deferred";
import {
MockDefaultOrganization,
MockNoOrganizationPermissions,
MockOrganization2,
MockOrganizationPermissions,
MockTemplate,
MockUserOwner,
mockApiError,
Expand All @@ -23,6 +28,16 @@ const mockSecondTemplate: Template = {
display_name: "Second Template",
};

const mockUnauthorizedTemplate: Template = {
...MockTemplate,
id: "unauthorized-template",
name: "unauthorized-template",
display_name: "Unauthorized Template",
organization_id: MockOrganization2.id,
organization_name: MockOrganization2.name,
organization_display_name: MockOrganization2.display_name,
};

type ToggleDeferreds = {
first: Deferred<Template | null>;
second: Deferred<Template | null>;
Expand All @@ -40,14 +55,19 @@ const meta = {
layout: "fullscreen",
user: MockUserOwner,
permissions: {
editDeploymentConfig: true,
updateTemplates: true,
updateAnyTemplate: true,
},
queries: [
{
key: getTemplatesQueryKey({ q: "" }),
data: [MockTemplate],
},
{
key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
data: {
[MockDefaultOrganization.id]: MockOrganizationPermissions,
},
},
],
},
} satisfies Meta<typeof TemplatesPage>;
Expand All @@ -62,6 +82,12 @@ export const ServerSideFilter: Story = {
key: getTemplatesQueryKey({ q: "" }),
data: [MockTemplate, mockSecondTemplate],
},
{
key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
data: {
[MockDefaultOrganization.id]: MockOrganizationPermissions,
},
},
],
},
beforeEach: () => {
Expand Down Expand Up @@ -97,6 +123,12 @@ export const ConcurrentToggles: Story = {
key: getTemplatesQueryKey({ q: "" }),
data: [MockTemplate, mockSecondTemplate],
},
{
key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
data: {
[MockDefaultOrganization.id]: MockOrganizationPermissions,
},
},
],
},
beforeEach: () => {
Expand Down Expand Up @@ -240,38 +272,90 @@ export const DisplaysFallbackMutationError: Story = {
},
};

export const NoDeploymentConfigPermission: Story = {
export const OrganizationTemplateAdmin: Story = {
parameters: {
permissions: {
editDeploymentConfig: false,
updateTemplates: true,
updateAnyTemplate: true,
viewAllUsers: true,
},
queries: [],
organizations: [MockDefaultOrganization, MockOrganization2],
queries: [
{
key: organizationsPermissions([
MockDefaultOrganization.id,
MockOrganization2.id,
]).queryKey,
data: {
[MockDefaultOrganization.id]: MockOrganizationPermissions,
[MockOrganization2.id]: MockNoOrganizationPermissions,
},
},
],
},
beforeEach: () => {
spyOn(API, "getTemplates").mockResolvedValue([]);
spyOn(API, "getTemplates").mockResolvedValue([
MockTemplate,
mockUnauthorizedTemplate,
]);
spyOn(API, "getUsers").mockResolvedValue({ users: [], count: 0 });
},
play: async () => {
const body = within(document.body);
expect(
await body.findByText("You don't have permission to view this page"),
).toBeInTheDocument();
expect(body.queryByText("Test Template")).not.toBeInTheDocument();
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Test Template")).toBeVisible();
expect(canvas.queryByText("Unauthorized Template")).not.toBeInTheDocument();
await waitFor(() => expect(API.getTemplates).toHaveBeenCalled());
},
};

export const PermissionsRetryRecovers: Story = {
parameters: {
queries: [],
},
beforeEach: () => {
const checkAuthorization = spyOn(API, "checkAuthorization");
checkAuthorization
.mockRejectedValueOnce(
mockApiError({ message: "Failed to load organization permissions." }),
)
.mockImplementation(async ({ checks }) =>
Object.fromEntries(
Object.keys(checks).map((key) => [
key,
key.endsWith(".updateTemplates"),
]),
),
);
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Failed to load templates.")).toBeVisible();
expect(API.getTemplates).not.toHaveBeenCalled();
expect(API.getUsers).not.toHaveBeenCalled();

await userEvent.click(canvas.getByRole("button", { name: "Retry" }));

expect(await canvas.findByText("Test Template")).toBeVisible();
await waitFor(() =>
expect(API.checkAuthorization).toHaveBeenCalledTimes(2),
);
await waitFor(() => expect(API.getTemplates).toHaveBeenCalled());
},
};

export const FetchesWhenAllowed: Story = {
parameters: {
permissions: {
editDeploymentConfig: true,
updateTemplates: true,
updateAnyTemplate: true,
viewAllUsers: true,
},
queries: [],
queries: [
{
key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
data: {
[MockDefaultOrganization.id]: MockOrganizationPermissions,
},
},
],
},
beforeEach: () => {
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
Expand All @@ -291,8 +375,7 @@ export const FetchesWhenAllowed: Story = {
export const NoUpdateTemplatesPermission: Story = {
parameters: {
permissions: {
editDeploymentConfig: true,
updateTemplates: false,
updateAnyTemplate: false,
},
},
play: async () => {
Expand Down
Loading
Loading