diff --git a/site/permissions.json b/site/permissions.json index fa4e3c3d6ba8c..c9a0ad94382e0 100644 --- a/site/permissions.json +++ b/site/permissions.json @@ -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" diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index cb6ceccb1a19a..4a0024011f660 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -43,6 +43,7 @@ export const Navbar: React.FC = () => { permissions.createAnyMCPServerConfig || permissions.updateAnyMCPServerConfig || permissions.deleteAnyMCPServerConfig || + permissions.updateAnyTemplate || canAccessAnyModel; const organizationMCPSharing = useCanShareOrganizationMCPServers( organizations, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index 02c58753df948..563d9dbc4a4ac 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -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: , + }, + { + path: "/ai/settings/templates", + element:

Templates

, + }, + ], + }), + }, + 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: { diff --git a/site/src/modules/management/AISettingsSidebarView.stories.tsx b/site/src/modules/management/AISettingsSidebarView.stories.tsx index 8b024d4840bde..05d5ccba5c173 100644 --- a/site/src/modules/management/AISettingsSidebarView.stories.tsx +++ b/site/src/modules/management/AISettingsSidebarView.stories.tsx @@ -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 { @@ -190,6 +190,7 @@ export const NoDeploymentConfig: Story = { permissions: { ...MockPermissions, editDeploymentConfig: false, + updateAnyTemplate: false, }, }, play: async ({ canvasElement }) => { @@ -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 }) => { diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index cccec41148e77..8cab67270ab5f 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -90,7 +90,7 @@ const AISettingsSidebarView: FC = ({ {permissions.editDeploymentConfig && (
MCP servers - {permissions.updateTemplates && ( + {permissions.updateAnyTemplate && ( Templates )} @@ -120,6 +120,11 @@ const AISettingsSidebarView: FC = ({
)} + {!permissions.editDeploymentConfig && permissions.updateAnyTemplate && ( +
+ Templates +
+ )} ); diff --git a/site/src/modules/permissions/organizations.ts b/site/src/modules/permissions/organizations.ts index 570a00b678d13..e70bf473a5424 100644 --- a/site/src/modules/permissions/organizations.ts +++ b/site/src/modules/permissions/organizations.ts @@ -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", diff --git a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx index c9d45e1dc719d..de236cc72fd97 100644 --- a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx +++ b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx @@ -40,6 +40,10 @@ export const AISettingsIndexRedirect = () => { return ; } + if (permissions.updateAnyTemplate) { + return ; + } + if (accessibleOrgsQuery.isLoading) { return ; } diff --git a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx index 1cf4f88462b7a..f555f400e16a5 100644 --- a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx @@ -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, @@ -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